Back to glossary

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 alertWhat triggers itSeverityOrdinary legitimate useWhy malware wants it
Install scriptsScripts that run automatically when the package is installed or builtMediumCompiling native addons, downloading platform binariesCode runs on npm install with no import needed
Shell accesschild_process and calls such as exec and spawnMediumBuild tools, CLIs, git wrappersDownload and launch second-stage payloads
Network accessfetch() or the net, dgram, dns, http, and https modulesMediumHTTP clients, SDKs, telemetryExfiltrate data, reach command and control
Filesystem accessfs reads and writesLowConfig loaders, bundlers, test runnersRead SSH keys, tokens, and wallet files
Environment variable accessprocess.env in Node.js, os.environ in PythonLowReading configurationHarvest CI secrets and cloud credentials
Uses evaleval() and similar dynamic executionMediumTemplate engines, some polyfillsRun code that never appears as source
Dynamic requirerequire() called with a computed valueLowPlugin loaders, optional dependenciesLoad modules whose names are hidden
Debug accessvm, reflection, and other debug or dynamic-execution featuresLowTest frameworks, REPLs, sandboxesExecute code outside normal module loading
URL stringsExternal URL or IP fragments in the codeLowAPI endpoints, documentation linksHard-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.

JavaScript
// 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.

Bash
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
Plain text
{ "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.

Capability
crypto-js4.2.0baseline
axios1.14.0HTTP client
plain-crypto-js4.2.1malicious
Install script
Shell access
Filesystem access
Network access
Env variables
URL strings
Obfuscation or computed module load
Figure 1. Capability profiles. Four signals appear in plain-crypto-js@4.2.1 that its clean baseline never had.

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.

Capabilitycrypto-js@4.2.0axios@1.14.0plain-crypto-js@4.2.1
Install scriptNoNoYes: postinstall runs node setup.js
Shell accessNoNoYes: child_process, launching curl, cscript, osascript, nohup
Filesystem accessNoNoYes: deletes setup.js and package.json, renames package.md
Network accessNoYes: http, https, http2Indirect, through curl in a shell
Environment variable accessNoYes: proxy settingsNot reported
URL stringsNoYesHidden: the command and control URL is encoded
Obfuscated code or computed module loadNoNoYes: 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.

JavaScript
// 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.

  1. Registry tarball, before installfull evidence
    Everything a scanner needs is on disk.
    postinstall hooksetup.js 4,209 B18 encoded strings
  2. npm runs postinstall
    node setup.js starts with your user's privileges.
  3. Decode and fingerprint
    Strings decoded at runtime; OS detected with os.platform().
  4. Fetch second stage
    Shell launches curl to sfrclak[.]com:8000 for a platform-specific payload.
  5. Erase evidence
    Deletes setup.js and package.json, renames package.md to package.json.
  6. node_modules, after installno evidence
    An ordinary copy of crypto-js, manifest at 4.2.0. The payload is already running.
    postinstall hooksetup.jsencoded strings
Figure 2. Observable evidence of plain-crypto-js@4.2.1 at each stage of install, from Socket's analysis.

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.

ApproachCatches first-seen malwareWhen it actsMain blind spotMain cost
Hash and pattern signatures (YARA, AV)NoAfter someone catalogues the sampleAny new or re-packed payloadLow; precise once a signature exists
CVE and advisory matchingNoAfter disclosure and publicationMalware, which never receives a CVELow, but most CVE alerts are not reachable
Static capability analysis of the registry artifactYesBefore installHeavy obfuscation, native code, logic resolved at runtimeFalse positives on legitimate capability use
Dynamic detonation in a sandboxYes, if triggeredBefore install, if run on the registry artifactSandbox-aware, geo-fenced, or time-delayed payloadsCompute per version; slower verdicts
Runtime enforcement (Node permission model, EDR)PartlyDuring executionCode already running with your privileges; per-process, not per-packageBreakage, tuning, coverage gaps
Anomaly detection and UEBA on maintainers and publishingSometimesAt or after publishNew accounts with no baseline; attackers using legitimate pipelinesNoisy 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.