# Malware Detection

> Malware detection is deciding whether code acts against its user, using signatures, static analysis, behavioral signals, AI review, and sandboxing.

Also known as: malware scanning, malicious package detection, package malware detection, open source malware detection

Source: https://socket.dev/glossary/malware-detection

Last updated: 2026-10-26

Malware detection, also called malware scanning, is the process of deciding whether software acts against the person running it, by matching known signatures, analyzing code statically, and observing it in a sandbox. For open source packages, malware detection must analyze each new version's actual code before install, because nearly every malicious package is new and many run during install.

Package malware has no signature on the day it ships. An attacker publishes a fresh version, and installers resolve to it within minutes. In the [axios compromise](https://socket.dev/blog/axios-npm-package-compromised), `plain-crypto-js@4.2.1` was published at 23:59:12 UTC on March 30, 2026. Socket's automated detection flagged it about six and a half minutes later. npm replaced it with a security holding package at 03:25:11 UTC, about three hours and 26 minutes after publication. Our position is that malware detection for dependencies has to run on every new version within minutes, and its verdict has to be enforced before install. Advisory and CVE feeds are a lagging blocklist. Malware does not get a CVE.

## How does malware detection work?

Malware detection works by collecting evidence about code from three angles and turning it into a verdict: what the code is, what it could do, and what it does when it runs. Each technique below answers one of those questions and is blind to the others.

| Technique | Evidence it examines | Catches | Misses |
| --- | --- | --- | --- |
| Signature and hash matching | File hashes, byte patterns, YARA rules | Re-uploads of known samples and families | Anything new, repacked, or regenerated |
| Static analysis | Parsed code: imports, API calls, string literals, control flow, manifests | Capabilities and suspicious structure, without running anything | Logic that is decoded, decrypted, or downloaded at runtime |
| Heuristics and machine learning | Weighted features learned from labeled samples | Variants of known behavior | Novel techniques and inputs crafted to fool the model |
| LLM review | Source code read as text, with context about the package | Intent in unusual code, plus an explanation a human can check | Prompt injection, context limits, model refusals |
| Dynamic analysis (sandboxing) | System calls, network traffic, and file writes during execution | Behavior that static reading cannot see, and second-stage payloads | Code that waits, checks its environment, or needs a live server |
| Human analysis | Reverse engineering with full context | Ground truth and attribution | Scale: humans cannot read every release |

No combination of these techniques is perfect, and the reason is mathematical. In 1987, Fred Cohen showed that deciding in general whether a program is a virus is undecidable, and the same limit applies to [malware](https://socket.dev/glossary/malware) of every kind. "Precise detection is undecidable, however statistical methods may be used," [Cohen wrote](https://web.eecs.umich.edu/~aprakash/eecs588/handouts/cohen-viruses.html). Every malware detector is therefore a classifier that trades missed malware against false alarms. The engineering question is where each layer sits on that curve, and what happens when it is wrong.

## Why package malware breaks the signature model

Endpoint antivirus grew up scanning executables that arrive in modest numbers through a few channels. Package registries invert each of those assumptions.

Anyone can publish. A registry account costs nothing, and a [malicious package](https://socket.dev/glossary/malicious-package) can be one of hundreds published under throwaway identities. In the [Ethereum smart contract campaign](https://socket.dev/blog/massive-npm-malware-campaign-leverages-ethereum-smart-contracts) we reported in October 2024, each of more than 100 malicious npm packages came from a distinct fake maintainer.

Every sample can be unique. A package is source text plus a manifest, so an attacker can generate a different file for every upload at no cost. Hash matching pays only when attackers reuse files, and then it is decisive. All 26 packages in the [StegaBin](https://socket.dev/blog/stegabin-26-malicious-npm-packages-use-pastebin-steganography) campaign shipped the same loader, `vendor/scrypt-js/version.js`, with a single SHA-256 hash. Socket's [Known malware](https://socket.dev/alerts/malware) alert uses this, firing on any package that contains a file previously confirmed as malware.

Installation executes code. npm has long run dependency [install scripts](https://socket.dev/glossary/postinstall-scripts) with the installing user's privileges, before anyone imports or reviews the package, and install time is where most package malware executes. Default-on install scripts were a design mistake that [pnpm 10](https://socket.dev/blog/pnpm-10-0-0-blocks-lifecycle-scripts-by-default) and [npm 12](https://socket.dev/blog/npm-12) have now corrected. As of October 2026, the current Node.js 24 LTS release still [bundles npm 11](https://nodejs.org/en/blog/release/v24.22.0), which runs them.

The registry's own response comes after the fact. Here is what the npm registry holds for plain-crypto-js, queried on October 26, 2026:

```sh
$ npm view plain-crypto-js versions time --json
{
  "versions": [
    "0.0.1-security.0"
  ],
  "time": {
    "created": "2026-03-31T04:26:32.907Z",
    "modified": "2026-03-31T04:26:35.731Z",
    "4.2.0": "2026-03-30T05:57:32.867Z",
    "4.2.1": "2026-03-30T23:59:12.278Z",
    "0.0.1-security": "2026-03-31T03:25:11.636Z",
    "0.0.1-security.0": "2026-03-31T04:26:33.055Z"
  }
}

$ curl -s -X POST https://registry.npmjs.org/-/npm/v1/security/advisories/bulk \
    -H 'content-type: application/json' -d '{"plain-crypto-js":["4.2.1"]}' \
  | jq -c '.["plain-crypto-js"][] | {url, title, severity, cwe, cvss: .cvss.score}'
{"url":"https://github.com/advisories/GHSA-2x9r-6wxq-hrr7","title":"Malware in plain-crypto-js","severity":"critical","cwe":["CWE-506"],"cvss":0}
```

The malicious tarball is gone, replaced by an empty package published by npm's own account. The [GitHub advisory](https://github.com/advisories/GHSA-2x9r-6wxq-hrr7) for it has no CVE ID and a CVSS score of 0. A policy that gates on CVSS scores ignores it entirely. The advisory exists only because someone detected the package first, which is the shape of every malware feed: a blocklist that trails detection.

## What a capability scanner sees, and what it misses

Static analysis for packages usually starts with capabilities: which install hooks a package declares, and whether its code touches the shell, the network, the filesystem, or environment variables. Capability signals are the raw material of [behavior-based security](https://socket.dev/glossary/behavior-based-security). To show where they break, we wrote a deliberately naive capability extractor and ran it on two packages.

```javascript
// caps.mjs: a deliberately naive capability extractor for one npm package directory.
// Usage: node caps.mjs <package-dir>
import fs from "node:fs";
import path from "node:path";
import * as acorn from "acorn";
import * as walk from "acorn-walk";

const dir = process.argv[2];
const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
const HOOKS = ["preinstall", "install", "postinstall", "prepare"];
const MODS = {
  child_process: "shell", http: "network", https: "network", net: "network",
  dgram: "network", dns: "network", fs: "filesystem", os: "system-info",
  vm: "dynamic-code",
};

const entropy = (s) => {
  const f = {};
  for (const c of s) f[c] = (f[c] || 0) + 1;
  return -Object.values(f).reduce((a, n) => a + (n / s.length) * Math.log2(n / s.length), 0);
};

const out = {
  installScripts: Object.fromEntries(HOOKS.filter((h) => pkg.scripts?.[h]).map((h) => [h, pkg.scripts[h]])),
  capabilities: new Set(),
  dynamicRequire: 0,
  highEntropyStrings: 0,
};

const files = [];
(function scan(d) {
  for (const e of fs.readdirSync(d, { withFileTypes: true })) {
    const p = path.join(d, e.name);
    if (e.isDirectory()) scan(p);
    else if (/\.(c|m)?js$/.test(e.name)) files.push(p);
  }
})(dir);

for (const f of files) {
  let ast;
  try {
    ast = acorn.parse(fs.readFileSync(f, "utf8"), { ecmaVersion: "latest", sourceType: "script", allowHashBang: true, allowReturnOutsideFunction: true });
  } catch {
    continue;
  }
  walk.full(ast, (n) => {
    if (n.type === "CallExpression" && n.callee.name === "require") {
      const a = n.arguments[0];
      if (a?.type === "Literal") { if (MODS[a.value]) out.capabilities.add(MODS[a.value]); }
      else out.dynamicRequire++;
    }
    if (n.type === "CallExpression" && n.callee.name === "eval") out.capabilities.add("dynamic-code");
    if (n.type === "NewExpression" && n.callee.name === "Function") out.capabilities.add("dynamic-code");
    if (n.type === "MemberExpression" && n.object.type === "MemberExpression" &&
        n.object.object.name === "process" && n.object.property.name === "env") out.capabilities.add("env-vars");
    if (n.type === "Literal" && typeof n.value === "string" && n.value.length >= 20 && entropy(n.value) > 4.5) out.highEntropyStrings++;
  });
}

out.capabilities = [...out.capabilities].sort();
console.log(JSON.stringify({ name: `${pkg.name}@${pkg.version}`, files: files.length, ...out }, null, 2));
```

The first target is esbuild@0.28.2, a widely used JavaScript bundler. Its postinstall script verifies, and if necessary downloads, a platform-specific native binary. As of October 26, 2026, 479 of esbuild's 482 published versions declare that hook, starting with 0.0.13 in April 2020. The second target is a defanged reconstruction of the string-hiding pattern Socket documented in plain-crypto-js@4.2.1. It decodes its module names at runtime and runs `echo` instead of a payload.

```json
{ "name": "defanged-dropper", "version": "4.2.1", "scripts": { "postinstall": "node setup.js" } }
```

```javascript
// Defanged reconstruction of the string-hiding pattern described in Socket's
// plain-crypto-js analysis. It decodes module names at runtime and runs `echo`.
const stq = ["__wczV2YvJHcfRGbph2Y", "_M3b", "_Mmb5N1YlhXZ", "__AZh9Gb5FGcg8mbgoDZldmbhZWZkByboNWZ"];
const d = (s) => Buffer.from(s.split("").reverse().join("").replace(/_/g, "="), "base64").toString();
const cp = require(d(stq[0]));
const os = require(d(stq[1]));
cp[d(stq[2])](d(stq[3]) + " on " + os.platform(), { stdio: "inherit" });
```

```sh
$ npm pack esbuild@0.28.2 && mkdir esbuild && tar xzf esbuild-0.28.2.tgz -C esbuild
$ node caps.mjs esbuild/package
{
  "name": "esbuild@0.28.2",
  "files": 2,
  "installScripts": {
    "postinstall": "node install.js"
  },
  "capabilities": [
    "env-vars",
    "filesystem",
    "network",
    "shell",
    "system-info"
  ],
  "dynamicRequire": 1,
  "highEntropyStrings": 0
}
$ node caps.mjs defanged
{
  "name": "defanged-dropper@4.2.1",
  "files": 1,
  "installScripts": {
    "postinstall": "node setup.js"
  },
  "capabilities": [],
  "dynamicRequire": 2,
  "highEntropyStrings": 0
}
```

The naive scanner gets the answer backwards. The benign bundler lights up every capability, and the dropper reports none, because the strings `child_process` and `execSync` never appear in its source. Literal capability matching rewards [code obfuscation](https://socket.dev/glossary/code-obfuscation). Even the dynamic-require counter is ambiguous: esbuild's one dynamic require loads its own package.json through `path.join`, while the dropper's two load module names decoded from Base64.

Three signals separate the two packages, and none of them is a single capability:

- Change between versions. plain-crypto-js@4.2.0 was a clean copy of crypto-js with no install script, and 4.2.1 added one 18 hours later. esbuild has shipped the same hook for six years.
- Concealment. The dropper loads modules through computed strings. Legitimate code rarely hides which module it loads, so the hiding is itself evidence.
- Fit with purpose. A copy of a crypto library has no reason to spawn processes. A bundler that ships a native binary does.

Static rules can express the first two signals. The third requires judging what a package is for, and that is where LLM review earns its cost.

## The package malware detection pipeline

A package malware detection pipeline runs cheap analysis on every version and expensive analysis on the few versions that earn it. Figure 1 shows the stages and how attackers target each one.

> Figure 1. A package malware detection pipeline. Cheap stages run on every version; expensive stages run on the few that earn them. Red boxes show how each stage is attacked or fails, with examples documented by Socket.
>
> The six stages of a package malware detection pipeline and the evasion technique that targets each stage

The same pipeline as a table, with the evidence behind each example:

| Stage | Runs on | What it does | How it is attacked or fails | Documented example |
| --- | --- | --- | --- | --- |
| 1. Ingest the registry feed | Every new version | Picks up each version minutes after publish, from the registry rather than GitHub | Volume, and clean first versions that turn malicious later | 100+ npm packages from distinct fake maintainers (Ethereum C2 campaign, Oct 2024); 73 GlassWorm sleeper clones on Open VSX (Apr 2026); clean plain-crypto-js 4.2.0 (Mar 2026) |
| 2. Unpack and diff | Every new version | Compares manifest, dependencies, and files with the previous version | Malicious code placed one dependency away | axios 1.14.1 mainly added a dependency on plain-crypto-js; GlassWorm added extensionPack entries in later updates |
| 3. Static analysis | Every new version | Parses code for install hooks, capabilities, obfuscation, entropy, and hashes of confirmed malware files | Encoded strings, compiled payloads, and C2 addresses fetched at runtime | 18 strings behind reversed Base64 and XOR (plain-crypto-js); ChaCha20-encrypted strings in WebAssembly (GlassWASM); C2 read from Ethereum, Solana, or Pastebin |
| 4. AI review | Suspicious subset | An LLM judges intent against the package's purpose | Prompt injection, refusal triggers, context flooding | shai\_hulululud@1.0.48596: fake SYSTEM OVERRIDE comments and more than 3.5 million tokens before the code (Jun 2026) |
| 5. Dynamic analysis | Selected samples | Runs the package or its second stage in a sandbox | Time bombs, gated C2 servers, self-deletion | NuGet triggers dated 2027 and 2028 with 20% odds per query; StegaBin single-use C2 tokens; plain-crypto-js deleting itself |
| 6. Human confirmation and enforcement | Flagged findings | A researcher confirms or rejects; confirmed verdicts are enforced at pull request and install time | Unconfirmed automated verdicts become false positives | 157 OSV malicious-package reports withdrawn (May 2026) |

Socket's pipeline follows this shape. We analyze the tarball published to the registry, not the GitHub repository, because the tarball is what installs. Static analysis runs on every package version. [Socket AI](https://socket.dev/blog/introducing-socket-ai-chatgpt-powered-threat-analysis), launched in March 2023, sends suspicious code to a large language model for a second read. The scanner raises three alerts: AI-detected potential malware, AI-detected potential security risk, and AI-detected potential code anomaly. Socket's default security policy has enabled only the first of these [since March 2024](https://socket.dev/blog/enhanced-security-scanning-with-improved-ai-alert-defaults). Every [AI-detected potential malware](https://socket.dev/alerts/gptMalware) finding then goes to human review, and confirmed findings become Known malware.

Speed comes from automation, not from the human step. In the [@antv wave](https://socket.dev/blog/antv-packages-compromised) of the [Mini Shai-Hulud](https://socket.dev/supply-chain-attacks/mini-shai-hulud) campaign on May 19, 2026, Socket detected 639 compromised versions with a median time of about 6.7 minutes after publication. For StegaBin, Socket flagged the first of 26 packages within two minutes of publication, and all 26 in under six minutes each.

Dynamic analysis earns its place after detection. For StegaBin, our researchers connected a simulated infected client to the live command server from a sandboxed VPN instance. The server pushed a nine-module infostealer toolkit, including a keylogger and a browser credential stealer that no npm package in the campaign contained. A [sandbox environment](https://socket.dev/glossary/sandbox-environment) shows what a payload does once it runs, which is exactly the part that static analysis of the package cannot see.

Registries now run detection too. Since July 28, 2026, [npm scans newly published packages](https://github.blog/changelog/2026-07-28-npm-publish-time-malware-scanning-and-dual-use-metadata/) before they become installable, typically for about five minutes, and can publish, hold, or block each one. npm's announcement concedes that some legitimate packages "can resemble malware to automated scanning," and gives their maintainers a `contentPolicy` field and a DISCLOSURE file to declare it. Registry scanning raises the floor, but it is one detector with one threshold. In the [keyv and cacheable compromise](https://socket.dev/supply-chain-attacks/keyv-and-cacheable-compromise) on August 4, 2026, trojanized versions still reached the registry, and Socket detected them an average of five minutes and 18 seconds after publication.

## Static analysis, AI review, and sandboxing compared

Each detection layer has a different cost and a different way of failing, and the right design uses each where its failure is cheapest.

| Layer | Runs on | Speed | Best at | Blind spot | How it fails |
| --- | --- | --- | --- | --- | --- |
| Hash and signature match | Every file | Milliseconds | Exact reuse, like StegaBin's shared loader | Any newly generated file | Silent miss |
| Static capability and structure analysis | Every version | Seconds | Install hooks, obfuscation, version-to-version diffs | Intent; logic decoded at runtime | Noise: esbuild looks like a dropper |
| LLM review | The suspicious subset | Seconds to minutes; costly per token | Intent in unusual code, with an explanation | Prompt injection, context limits, refusals | Fails open if a refusal or timeout counts as clean |
| Sandbox execution | Selected samples | Minutes or longer | Network behavior and second-stage payloads | Time bombs, geofencing, token-gated servers | Silent miss when the payload never fires |
| Human researcher | Flagged findings | Minutes to hours | Ground truth and attribution | Volume | Latency |

Our verdict: run static analysis on every version, because it is cheap and catches the install-time structure that most package malware needs. Send the suspicious subset to an LLM to judge intent. Use sandboxes to confirm findings and capture second stages, not as the gate, because the malware decides whether to run. Block human-confirmed malware by default, and treat AI-only verdicts as warnings unless your risk tolerance says to block them. Treat every refusal, timeout, and parse failure as unknown, never as clean.

## How does new malware evade detection?

New malware evades detection by attacking whichever layer it expects to face: uniqueness defeats signatures, encoding defeats static reading, waiting defeats sandboxes, and prompt injection targets AI reviewers. Every technique below comes from a package Socket analyzed.

### Hide the strings

The plain-crypto-js dropper hid all 18 of its sensitive strings, including `child_process`, its C2 URL, and its shell commands, behind reversed Base64 and an XOR cipher keyed on `OrDeR_7077`. The [GlassWASM](https://socket.dev/blog/glasswasm-malware-open-vsx-extensions) extensions went further. Their loader is WebAssembly compiled from Go with TinyGo, and every meaningful string is encrypted with ChaCha20. Socket found no plaintext URLs or commands in the binary.

### Keep the payload off the package

Static analysis can only read what ships. The Ethereum smart contract campaign stored its server address in a contract and read it at install time. GlassWASM reads its instructions from Solana transaction memos. StegaBin hid a list of 31 Vercel domains inside Pastebin essays by replacing characters at evenly spaced positions. In each case the package contains only a lookup, and the [command and control (C2)](https://socket.dev/glossary/command-and-control-c2) server decides what runs next.

### Wait

Nine NuGet packages published by the alias shanhai666 accumulated 9,488 downloads while providing real, working database code. After trigger dates in 2027 and 2028, [the NuGet time bombs](https://socket.dev/blog/9-malicious-nuget-packages-deliver-time-delayed-destructive-payloads) terminate the host process with 20% probability on each database query. [GlassWorm](https://socket.dev/supply-chain-attacks/glassworm-v2) waits at the registry level instead. Socket tracked 73 cloned Open VSX extensions that were published clean, and at least six had been updated to deliver malware by April 2026. plain-crypto-js used the same pattern on a smaller scale, with a clean 4.2.0 published 18 hours before the malicious 4.2.1.

### Look legitimate

The NuGet time bombs buried a payload of about 20 lines inside thousands of lines of working repository-pattern code. Most of StegaBin's typosquats declared the legitimate package they impersonated as a dependency, so a victim's application kept working after the mistaken install.

### Move the payload one hop away

axios@1.14.1 carried no payload of its own. Its main change was a new dependency on plain-crypto-js. GlassWorm did the same on Open VSX, adding `extensionPack` and `extensionDependencies` entries in later versions so that a clean-looking extension installs a malicious one. A scanner that judges each package in isolation, without the dependency diff, misses both.

### Erase the evidence

After running, the plain-crypto-js dropper deleted `setup.js`, deleted its own `package.json`, and renamed a bundled `package.md` to `package.json`, restoring the clean 4.2.0 manifest. The installed `node_modules/plain-crypto-js` directory then looks like an ordinary copy of crypto-js. A scan of the installed tree after the fact finds nothing.

### Attack the scanner

AI review created a new target. Newer payloads in the Mini Shai-Hulud, [Miasma](https://socket.dev/supply-chain-attacks/miasma-mini-shai-hulud-supply-chain-attack), and [Hades](https://socket.dev/blog/mini-shai-hulud-miasma-and-hades-worms-target-bioinformatics-and-mcp-developers-via-malicious) waves began with fake prompt-injection headers. Then, in June 2026, [shai\_hulululud@1.0.48596](https://socket.dev/blog/npm-package-uses-prompt-injection-and-token-flooding-to-disrupt-ai-malware-scanners) shipped a 9.28 MB `index.js` aimed squarely at AI reviewers. It opened with weapons-themed text meant to trigger model refusals, then a fake "SYSTEM OVERRIDE" block, then one comment repeated from line 191 to line 33,118, with obfuscated JavaScript at the very end. The file exceeds 3.5 million tokens, which Socket noted in June 2026 was well beyond the context windows of frontier models. A scanner that truncates, refuses, or times out never reaches the code. Socket classified the package as protestware or potentially unwanted behavior.

## False positives are a detection failure too

A false malware verdict is not a harmless extra alert. On May 26, 2026, [OSV withdrew 157 malicious-package reports](https://socket.dev/blog/osv-withdraws-157-malware-reports) after automated detections flagged legitimate npm and PyPI projects. One withdrawn record, [MAL-2026-4750](https://osv.dev/vulnerability/MAL-2026-4750), labeled fastapi 0.136.3 as malicious because the release added an undocumented dependency, fastar. The record cited no payload, no install-time execution, and no exfiltration. It was published May 23 and withdrawn three days later, with Amazon Inspector credited as the finder.

Malware verdicts carry more operational weight than vulnerability alerts. A vulnerability can be triaged by version range and reachability. A malware verdict triggers build failures, package removal, credential rotation, and incident response. Our position is that an automated malware verdict should not reach an enforcement feed until someone has confirmed a payload or a malicious behavior. A suspicious change is not malicious code.

Requiring confirmation has a cost. Human review adds minutes to hours, and the AI alert is the only signal in that window. [Socket Firewall Free](https://docs.socket.dev/docs/socket-firewall-overview) blocks confirmed malware and only warns on AI-detected threats. The Enterprise version lets teams block AI-detected malware too, trading some false positives for a shorter exposure window.

## Where should malware detection run?

Malware detection should run before install, at the point where a dependency enters a repository, a CI runner, a laptop, or an agent's sandbox. Detection that runs later sees less. The plain-crypto-js dropper erased itself from node\_modules, so a post-install scan finds a clean directory. Endpoint tools can watch the install process, but they judge behavior as it happens, after the hook is already running with access to the credentials on disk.

The window that matters is between detection and takedown. Socket flagged plain-crypto-js@4.2.1 about six and a half minutes after publication, and the registry replaced it about three hours and 20 minutes after that. A verdict protects no one unless something enforces it in that window: a pull request check when a dependency changes, or an install-time [package firewall](https://socket.dev/glossary/package-firewall) that refuses the download.

A [dependency cooldown](https://socket.dev/glossary/dependency-cooldown) covers the same window without a detector, by refusing any version younger than a set age. It is a useful backstop, but it [delays security patches](https://socket.dev/blog/the-code-you-didnt-write-is-still-yours-to-defend) by the same amount, at a time when the gap from patch to working exploit is measured in hours. Time bombs also outwait any fixed window: the shanhai666 packages were published in 2023 and 2024 with triggers set for 2027 and 2028.

The open problem is that the detector is now part of the attack surface. Packages that flood context windows and trigger refusals are cheap to produce, and they work against any pipeline that treats a non-answer as a pass. Socket Threat Research put the requirement plainly: "A model refusal, timeout, or safety error should not be treated as a clean result."
