Behavior-Based Security
Behavior-based security detects threats by what code, users, or devices actually do, such as spawning shells or reading secrets, not by known signatures.
Also known as Behavioral analysis, Behavior-based detection, Behavioral security, Capability analysis
Behavior-based security, also called behavioral analysis or behavior-based detection, judges code, users, or devices by what they do, like spawning shells or reading credentials, rather than matching known signatures. For open source packages, it means extracting each version's capabilities from its code and flagging behavior that does not fit. It catches malware that has no signature yet.
The technique is older than package registries. Antivirus heuristics, endpoint detection and response (EDR), and user and entity behavior analytics (UEBA) all model normal activity and alert on deviations. Package ecosystems suit it unusually well, because a package's behavior is written down in a tarball that anyone can read before it runs. The strongest signal is not any single capability. It is a capability that appears for the first time in a new version.
On March 30, 2026, the axios compromise pulled in plain-crypto-js@4.2.1, a copy of crypto-js that added a postinstall hook, shell execution, and file deletion. Socket's automated detection flagged it at 00:05:41 UTC on March 31, six and a half minutes after publish. No CVE existed for it, and none ever will: malware is not a vulnerability. Our position is that capability analysis of the registry artifact, before install, should be the default control for third-party code, with CVE matching treated as a separate problem.
What is behavioral analysis in cybersecurity?
#Behavioral analysis in cybersecurity is the general form of behavior-based security: it detects threats from observed actions instead of from known indicators. A signature-based tool asks whether this file, hash, or byte pattern has been seen in malware before. A behavior-based tool asks whether this process, account, or package is doing something it should not.
Three families of tools share the idea:
- Endpoint behavioral analysis watches process trees, file writes, and network connections on a host. A word processor spawning PowerShell is a classic detection, whatever the dropped file's hash.
- Heuristic analysis scores static features of code: packers, encrypted strings, imports of process-injection APIs. It flags a file that looks like malware before anyone has catalogued it.
- Anomaly detection and UEBA build statistical baselines for users, hosts, and service accounts, then score deviations such as a 3 a.m. login or a sudden bulk download. User behavior analytics (UBA) is the older, users-only form.
All three share a weakness. "Normal" is learned from history, so a brand-new entity has no baseline, and a patient attacker can become the baseline. Every behavioral system also trades detection rate against alert volume. Those two problems follow the approach into package security.
How does behavior-based security work for open source packages?
#Behavior-based security for open source packages works by reading what a package's code can do before the package runs. That matters because installing a package is code execution. npm install runs third-party code with the full privileges of your user, and neither the registry nor the runtime asks what that code needs.
Node.js added a permission model in v20.0.0 and marked it stable in v22.13.0 and v23.5.0. It is opt-in, applies to the whole process rather than to each package, and is explicit about its limits. The Node.js documentation says the model "does not provide security guarantees in the presence of malicious code." Every dependency in a Node process inherits everything the process can do.
Install time is where this bites hardest. In a 2020 review of 174 malicious packages from npm, PyPI, and RubyGems, Ohm et al. found that "most malicious packages (56%) start their routines on installation."
So the only reliable place to learn a package's behavior is its code. A capability is a privileged operation that the package's code can reach: opening a socket, spawning a child process, reading files, reading environment variables, or evaluating strings as code. Extracting capabilities is a static analysis problem. Parse every file, resolve imports and require calls against the platform's built-in modules, record each call site, and read package.json for lifecycle hooks.
Socket exposes these capabilities as individual alerts on every package version. As of November 2026, the capability alerts and their published severities are:
| Socket alert | What triggers it | Severity | Ordinary legitimate use | Why malware wants it |
|---|---|---|---|---|
| Install scripts | Scripts that run automatically when the package is installed or built | Medium | Compiling native addons, downloading platform binaries | Code runs on npm install with no import needed |
| Shell access | child_process and calls such as exec and spawn | Medium | Build tools, CLIs, git wrappers | Download and launch second-stage payloads |
| Network access | fetch() or the net, dgram, dns, http, and https modules | Medium | HTTP clients, SDKs, telemetry | Exfiltrate data, reach command and control |
| Filesystem access | fs reads and writes | Low | Config loaders, bundlers, test runners | Read SSH keys, tokens, and wallet files |
| Environment variable access | process.env in Node.js, os.environ in Python | Low | Reading configuration | Harvest CI secrets and cloud credentials |
| Uses eval | eval() and similar dynamic execution | Medium | Template engines, some polyfills | Run code that never appears as source |
| Dynamic require | require() called with a computed value | Low | Plugin loaders, optional dependencies | Load modules whose names are hidden |
| Debug access | vm, reflection, and other debug or dynamic-execution features | Low | Test frameworks, REPLs, sandboxes | Execute code outside normal module loading |
| URL strings | External URL or IP fragments in the code | Low | API endpoints, documentation links | Hard-coded payload hosts and drop sites |
Severities come from Socket's public alert pages, such as Network access. On Socket package pages, each alert links to the line of code that triggered it.
Why a capability diff beats a capability list
#A capability list on its own says little. Many popular packages legitimately touch the network, the filesystem, or the environment. The useful question is what a version can do that the previous version could not, and whether that fits what the package is for.
You can reproduce the first half of this yourself. The script below, caps.mjs, is a toy capability extractor of 65 lines. It parses every JavaScript file in an unpacked tarball with acorn, maps built-in module imports to capabilities, and flags eval, computed require calls, process.env reads, URL strings, and install hooks. A production analyzer also tracks aliasing, re-exports, data flow, and other ecosystems, but the principle is the same.
// caps.mjs: a toy capability extractor for an unpacked npm package.
// 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 CAP = {
network: ['http', 'https', 'net', 'dgram', 'dns', 'tls', 'http2'],
shell: ['child_process'],
filesystem: ['fs', 'fs/promises'],
debug: ['vm', 'inspector', 'v8'],
}
const modCap = {}
for (const [cap, mods] of Object.entries(CAP)) for (const m of mods) modCap[m] = modCap['node:' + m] = cap
const dir = process.argv[2]
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
const found = {} // capability -> Set of "file:line"
const hit = (cap, file, node) => (found[cap] ??= new Set()).add(`${file}:${node.loc.start.line}`)
for (const h of ['preinstall', 'install', 'postinstall']) {
if (pkg.scripts?.[h]) (found.installScripts ??= new Set()).add(`package.json scripts.${h}`)
}
function files(d) {
return fs.readdirSync(d, { withFileTypes: true }).flatMap((e) => {
const p = path.join(d, e.name)
if (e.isDirectory()) return e.name === 'node_modules' ? [] : files(p)
return /\.(c|m)?js$/.test(e.name) ? [p] : []
})
}
for (const f of files(dir)) {
const rel = path.relative(dir, f)
let ast
try {
ast = acorn.parse(fs.readFileSync(f, 'utf8'), { ecmaVersion: 'latest', sourceType: 'module', locations: true, allowHashBang: true, allowReturnOutsideFunction: true })
} catch {
try { ast = acorn.parse(fs.readFileSync(f, 'utf8'), { ecmaVersion: 'latest', sourceType: 'script', locations: true, allowHashBang: true, allowReturnOutsideFunction: true }) } catch { continue }
}
const mod = (name, node) => modCap[name] && hit(modCap[name], rel, node)
walk.full(ast, (n) => {
if (n.type === 'ImportDeclaration') mod(n.source.value, n)
if (n.type === 'ImportExpression' && n.source.type !== 'Literal') hit('dynamicRequire', rel, n)
if (n.type === 'CallExpression') {
const c = n.callee
if (c.type === 'Identifier' && c.name === 'require') {
const a = n.arguments[0]
if (a?.type === 'Literal') mod(a.value, n)
else hit('dynamicRequire', rel, n)
}
if (c.type === 'Identifier' && c.name === 'eval') hit('eval', rel, n)
if (c.type === 'Identifier' && c.name === 'fetch') hit('network', rel, n)
}
if (n.type === 'NewExpression' && n.callee.name === 'Function') hit('eval', rel, n)
if (n.type === 'MemberExpression' && n.object.type === 'MemberExpression' &&
n.object.object.name === 'process' && n.object.property.name === 'env') hit('envVars', rel, n)
if (n.type === 'Literal' && typeof n.value === 'string' &&
/\bhttps?:\/\/(?!(www\.)?(w3\.org|github\.com|npmjs\.(com|org)))/.test(n.value)) hit('urlStrings', rel, n)
})
}
const out = Object.fromEntries(Object.entries(found).map(([k, v]) => [k, { count: v.size, first: [...v].slice(0, 2) }]))
console.log(JSON.stringify({ package: `${pkg.name}@${pkg.version}`, capabilities: out }, null, 2))Run it against two real packages from the npm registry. The output below is condensed to one object per package.
npm install acorn@8 acorn-walk@8
npm pack crypto-js@4.2.0 axios@1.14.0
mkdir -p crypto-js axios
tar xzf crypto-js-4.2.0.tgz -C crypto-js
tar xzf axios-1.14.0.tgz -C axios
node caps.mjs crypto-js/package
node caps.mjs axios/package{ "package": "crypto-js@4.2.0", "capabilities": {} }
{ "package": "axios@1.14.0", "capabilities": {
"urlStrings": { "count": 7, "first": ["dist/axios.js:1937", "dist/axios.min.js:4"] },
"network": { "count": 6, "first": ["dist/node/axios.cjs:7", "dist/node/axios.cjs:8"] },
"envVars": { "count": 1, "first": ["dist/node/axios.cjs:2075"] } } }crypto-js@4.2.0 reaches no privileged capability at all. Its only built-in import is crypto, for secure random numbers. axios@1.14.0 requires http, https, and http2 on lines 7 to 9 of its Node build, and reads proxy settings from process.env on line 2075. Network access in an HTTP client is expected. Any capability in a pure-JavaScript crypto library is not.
The attacker behind plain-crypto-js understood baselines. Version 4.2.0 was published at 05:57 UTC on March 30, 2026, as a clean copy of crypto-js with no payload. Version 4.2.1 followed at 23:59:12 UTC, a patch bump 18 hours later. Both timestamps are still visible in the npm registry document for plain-crypto-js, even though npm has since replaced the package with a security placeholder. The clean release gave the name a history. The patch release was the attack.
The figure shows three package versions against seven capabilities. crypto-js@4.2.0 has none of them. axios@1.14.0 has network access, environment variable access, and URL strings, all consistent with an HTTP client. plain-crypto-js@4.2.1 has an install script, shell access, filesystem access, obfuscated code, and a computed module load. Its network traffic runs through curl in a spawned shell, so it appears as shell access rather than as a network import. Its command and control URL is encoded, so the URL strings check sees nothing. The plain-crypto-js column is reconstructed from Socket's published analysis of the malicious setup.js, because npm no longer serves the tarball.
| Capability | crypto-js@4.2.0 | axios@1.14.0 | plain-crypto-js@4.2.1 |
|---|---|---|---|
| Install script | No | No | Yes: postinstall runs node setup.js |
| Shell access | No | No | Yes: child_process, launching curl, cscript, osascript, nohup |
| Filesystem access | No | No | Yes: deletes setup.js and package.json, renames package.md |
| Network access | No | Yes: http, https, http2 | Indirect, through curl in a shell |
| Environment variable access | No | Yes: proxy settings | Not reported |
| URL strings | No | Yes | Hidden: the command and control URL is encoded |
| Obfuscated code or computed module load | No | No | Yes: 18 encoded strings, including module names |
Four new signals appeared together in a patch release of a crypto library. That combination is a far stronger indicator than any one of them alone. It is also a pattern we described in 2022 as permission creep: attackers publish in patch versions to reach the most victims through caret ranges. Any project on ^1.14.0 or ^0.30.0 would have resolved the poisoned axios releases on its next install that re-resolved the range.
How do attackers evade behavior-based security?
#Attackers evade behavior-based security by hiding capabilities from static extraction, by moving execution to paths that scanners do not watch, and by erasing evidence before anyone looks. Each technique has a cost, and the cost is usually visible.
Hiding capabilities. The plain-crypto-js dropper never contains the literal string child_process. Socket's analysis found 18 strings, including the module names child_process, os, and fs, behind two layers of encoding: reversed base64, then an XOR keyed on OrDeR_7077.
// Deobfuscation functions from setup.js, as reconstructed by Socket
const _trans_1 = function(x, r) {
const E = r.split("").map(Number);
return x.split("").map((x, r) => {
const S = x.charCodeAt(0), a = E[7 * r * r % 10];
return String.fromCharCode(S ^ a ^ 333);
}).join("");
};
const _trans_2 = function(x, r) {
let E = x.split("").reverse().join("").replaceAll("_", "=");
let S = Buffer.from(E, "base64").toString("utf8");
return _trans_1(S, r);
};A scanner that only matches require('child_process') misses this. The hiding itself is still a behavior, though. Code obfuscation, high-entropy strings, and a module name computed at runtime are signals in their own right, and Socket rates Obfuscated code as High severity, above any single capability alert. Legitimate libraries have little reason to encrypt the names of the built-in modules they load.
Unwatched execution paths. Scanners that check only preinstall and postinstall fields can be bypassed. The Miasma worm's "Phantom Gyp" waves added a binding.gyp file instead. npm runs node-gyp automatically when that file exists, and command expansion in the file executes JavaScript during build configuration. A behavior model has to cover every way the package manager runs code, not just the documented hook names. npm v12 now blocks dependency lifecycle scripts and the implicit node-gyp rebuild by default, and pnpm 10 blocked lifecycle scripts by default before it. Both changes correct a default that should never have shipped.
Anti-analysis. Dynamic analysis, which detonates a package in a sandbox and records what it does, catches behavior that static analysis cannot resolve. Malware checks for sandboxes. The ua-parser-js compromise of October 2021 looked up the victim's country by IP and exited early in Russia, Ukraine, Belarus, and Kazakhstan. Time bombs, CI-only triggers, and checks for analysis tooling all mean a clean detonation is not proof of a clean package.
Anti-forensics. After launching its payload, plain-crypto-js deleted setup.js, deleted its own package.json, and renamed a bundled package.md into place. That file was the clean 4.2.0 manifest with no postinstall hook. Anyone inspecting node_modules afterward found an ordinary copy of crypto-js.
-
Registry tarball, before installfull evidenceEverything a scanner needs is on disk.postinstall hooksetup.js 4,209 B18 encoded strings
-
npm runs
postinstallnode setup.jsstarts with your user's privileges. -
Decode and fingerprintStrings decoded at runtime; OS detected with
os.platform(). -
Fetch second stageShell launches
curltosfrclak[.]com:8000for a platform-specific payload. -
Erase evidenceDeletes
setup.jsandpackage.json, renamespackage.mdtopackage.json. -
node_modules, after installno evidenceAn ordinary copy of crypto-js, manifest at 4.2.0. The payload is already running.postinstall hooksetup.jsencoded strings
The figure follows npm install plain-crypto-js@4.2.1 through six stages. In the registry tarball before install, every malicious artifact is present: the postinstall hook, setup.js at 4,209 bytes, and the encoded strings. During install, npm runs postinstall, which runs node setup.js. The dropper then decodes its strings, detects the operating system with os.platform(), and downloads a platform-specific second stage from sfrclak[.]com:8000. It deletes setup.js and package.json and renames package.md to package.json. After install, node_modules/plain-crypto-js looks like an ordinary copy of crypto-js, and its package.json reports version 4.2.0 with no install hook. Only the first stage gives a scanner complete evidence, which is why analysis has to happen before install.
Indirection. The compromised axios releases changed almost nothing in axios itself. Socket's analysis found a minimal change that primarily added one new entry to dependencies. The new behavior lived one hop down, in a new transitive dependency. The publish was anomalous too: axios@1.14.1 had no matching tag in the axios GitHub repository, a break from the project's normal release workflow that Socket called out in its first report. Behavior-based security for packages has to run over the resolved dependency tree, not over the package a developer typed.
Behavior-based security vs signature-based detection
#Signature-based detection and behavior-based security fail in opposite directions. Signatures are precise but blind to anything new. Behavioral signals see new attacks but fire on legitimate code. The useful comparison is by what each control can see, and when.
| Approach | Catches first-seen malware | When it acts | Main blind spot | Main cost |
|---|---|---|---|---|
| Hash and pattern signatures (YARA, AV) | No | After someone catalogues the sample | Any new or re-packed payload | Low; precise once a signature exists |
| CVE and advisory matching | No | After disclosure and publication | Malware, which never receives a CVE | Low, but most CVE alerts are not reachable |
| Static capability analysis of the registry artifact | Yes | Before install | Heavy obfuscation, native code, logic resolved at runtime | False positives on legitimate capability use |
| Dynamic detonation in a sandbox | Yes, if triggered | Before install, if run on the registry artifact | Sandbox-aware, geo-fenced, or time-delayed payloads | Compute per version; slower verdicts |
| Runtime enforcement (Node permission model, EDR) | Partly | During execution | Code already running with your privileges; per-process, not per-package | Breakage, tuning, coverage gaps |
| Anomaly detection and UEBA on maintainers and publishing | Sometimes | At or after publish | New accounts with no baseline; attackers using legitimate pipelines | Noisy without context |
Our verdict: for third-party packages, static capability analysis of the registry artifact is the primary control. It is the only one that sees the complete package, before any of it runs, for every version. Use dynamic analysis to resolve what static analysis cannot, especially encoded strings and native code. Keep signatures and advisory matching for what they do well, known samples and known vulnerabilities. Treat runtime enforcement and EDR as the layer that limits damage once prevention has failed, not as prevention.
What behavior-based security costs
#The cost of behavior-based security is false positives. Many packages that open sockets are HTTP clients or SDKs, and many packages that read environment variables are reading configuration. A system that blocks on raw capabilities would block axios, as the scan above shows, along with a large share of ordinary dependencies.
Socket's default security policy reflects that. As of November 2026, it blocks only Known Malware by default. Obfuscated code is set to warn, and the individual capability alerts, including Install scripts, are not enabled unless a team turns them on. Capabilities are evidence for a malware detection verdict. They are not verdicts by themselves.
The failure mode of skipping that step is now documented. On May 26, 2026, OSV withdrew 157 malicious-package reports after automated detections flagged trusted npm and PyPI releases. The FastAPI record cited a new optional dependency as suspicious and identified no payload. A behavioral change was promoted to a malicious package record, and that record flowed into scanners and CI gates that treat OSV as an enforcement source. A capability or manifest change should open an investigation. Only a confirmed payload should turn it into a malware verdict.
The deeper limit is structural. A browser extension declares its permissions in a manifest, and the browser enforces them. An npm or PyPI package declares nothing, and Node's permission model applies to the whole process, so a date formatter and a database driver in the same process get the same rights. Until registries accept per-package capability declarations and runtimes enforce them, behavior-based security for open source remains inference from code. Done before install, it is very good inference, but the package still runs with everything you have.
