Back to glossary

Package Risk Assessment

Package risk assessment answers "is this npm package safe?" by checking one version's code, install behavior, publisher, and dependencies before install.

Also known as dependency vetting, package risk analysis, dependency risk assessment, open source package evaluation, npm package safety check, how to check if an npm package is safe

Package risk assessment, also called dependency vetting, is the process of answering "is this npm package safe?" before installing it, by checking a specific version's code, install-time behavior, publisher, release history, dependencies, vulnerabilities, and license. Safety belongs to a version, not a package name, so the assessment has to run again on every release you resolve, including transitive ones.

Most checklists for this question score the project: weekly downloads, GitHub stars, recent commits, maintainer count, known CVEs. Those signals tell you whether a project is worth depending on. They say almost nothing about whether its next version is safe. When the axios compromise began on March 31, 2026, axios had about 100 million weekly downloads, a maintainer shipping releases every few weeks, and a trusted publishing pipeline. axios@1.14.1 still pulled in a dependency that installed a remote access trojan.

Our position is that package risk assessment has two layers, and blending them into one number is how teams get burned. Project signals decide whether to adopt a package at all. Version signals, read from the published tarball and from its difference against the previous release, decide whether a specific version may run on your machine. Every compromise of a popular package we examine below passed the first layer and failed the second.

How do you check if an npm package is safe?

#

You check if an npm package is safe by assessing the exact version you will install, starting where attacks actually happen: what it executes at install time, what changed since the previous release, who published it and through which pipeline, and only then how healthy the project is.

The registry exposes most of this before you download anything. npm view reads a version's manifest from the packument, the JSON document the npm registry keeps for every package:

Bash
$ npm view axios@1.14.0 _npmUser.name \
    dist.attestations.provenance.predicateType dependencies --json
{
  "_npmUser.name": "GitHub Actions",
  "dist.attestations.provenance.predicateType": "https://slsa.dev/provenance/v1",
  "dependencies": {
    "form-data": "^4.0.5",
    "proxy-from-env": "^2.1.0",
    "follow-redirects": "^1.15.11"
  }
}

Three fields carry most of the signal. The registry sets _npmUser to the account or trusted publisher that performed the publish, so a package author cannot forge it in package.json. dist.attestations points to a Sigstore-signed provenance statement that ties the tarball to a source commit and a CI workflow. dependencies lists what else npm will fetch, and any entry a lockfile does not pin is resolved against the registry at install time. A preinstall, install, or postinstall entry under scripts means the version runs code during install, before anything imports it. Those install scripts are where most npm malware executes. npm v12 stopped running them for dependencies by default in July 2026; npm 11 and earlier still run them.

One version in isolation tells you little, because legitimate packages also add dependencies and ship install hooks. The signal is the change. This script compares a version with the release before it on the same major line, and checks how old each newly added dependency was at publish time:

JavaScript
// Usage: node check-version.mjs <name> <version>
const [name, version] = process.argv.slice(2);
const doc = await (await fetch(`https://registry.npmjs.org/${name}`)).json();
const v = doc.versions[version];
const major = version.split('.')[0];
const prior = Object.keys(doc.versions)
  .filter(x => x.split('.')[0] === major && doc.time[x] < doc.time[version])
  .sort((a, b) => doc.time[a] < doc.time[b] ? -1 : 1).pop();
const p = doc.versions[prior];
const hooks = m => Object.keys(m.scripts ?? {})
  .filter(s => ['preinstall', 'install', 'postinstall'].includes(s));
const deps = m => Object.keys(m.dependencies ?? {});
const hours = (a, b) => Math.round((new Date(b) - new Date(a)) / 36e5);
const newDeps = [];
for (const d of deps(v).filter(d => !deps(p).includes(d))) {
  const dd = await (await fetch(`https://registry.npmjs.org/${d}`)).json();
  newDeps.push({ name: d, ageHoursAtPublish: hours(dd.time.created, doc.time[version]) });
}
console.log(JSON.stringify({
  version, prior,
  publisher: [p._npmUser?.name, v._npmUser?.name],
  provenance: [!!p.dist.attestations, !!v.dist.attestations],
  installHooks: [hooks(p), hooks(v)],
  newDependencies: newDeps,
}, null, 2));

We ran it against axios@1.16.1 on October 26, 2026:

Bash
$ node check-version.mjs axios 1.16.1
{
  "version": "1.16.1",
  "prior": "1.16.0",
  "publisher": ["GitHub Actions", "GitHub Actions"],
  "provenance": [true, true],
  "installHooks": [[], []],
  "newDependencies": [
    { "name": "https-proxy-agent", "ageHoursAtPublish": 112580 }
  ]
}

axios@1.16.1 added a dependency, which is exactly what the malicious 1.14.1 did. Context clears it. https-proxy-agent was first published in July 2013, the release came through the same GitHub Actions trusted publisher as the release before it, and provenance was present on both sides. The same comparison on 1.14.1 would have shown a dependency 18 hours old and a different publisher.

The project layer comes after that, and it answers a different question: should this code be in the tree at all? Check whether the functionality is worth a dependency, how many maintainers can publish and how recently each one did, whether releases are regular, what license applies, and whether npm audit reports known vulnerabilities for the version you picked.

Project signals and version signals answer different questions

#

Project signals accumulate over years and change slowly. Version signals exist for one release and change in minutes. An attacker who takes over a maintainer account inherits every project signal for free, because downloads, stars, and history belong to the package name, and the name is exactly what was taken.

Project layer

axios

Is this project worth depending on?

  • ✓About 100 million weekly downloads
  • ✓Releases every few weeks on the 1.x line
  • ✓1.x published by a GitHub Actions trusted publisher since 1.13.4 (Jan 27, 2026)
  • ✓Three runtime dependencies, MIT license
Passes every adoption check

Version layer

axios@1.14.1

Is this exact tarball safe to install?

  • !Publisher changed: maintainer's personal account, not the trusted publisher used for 1.13.4 through 1.14.0
  • !New dependency plain-crypto-js, first published 18 h 24 m earlier
  • !That dependency added a postinstall hook 22 min before this release
  • !No matching release tag in the GitHub repository
Fails on four version signals
Figure 1. The same package on March 31, 2026, assessed at two layers. Project signals were all healthy. Every warning sign lived in the version and in its difference from axios@1.14.0.

Figure 1 shows axios on March 31, 2026 at both layers. At the project layer it passed: about 100 million weekly downloads, releases every few weeks on the 1.x line, a GitHub Actions trusted publisher on 1.x since 1.13.4 on January 27, 2026, three runtime dependencies, and an MIT license. At the version layer, axios@1.14.1 failed four checks: it was published from the maintainer's personal account rather than the trusted publisher used for 1.13.4 through 1.14.0; it added a dependency, plain-crypto-js, whose first version was 18 hours and 24 minutes old; that dependency had added a postinstall hook 22 minutes before axios@1.14.1 appeared; and the release had no matching tag in the axios GitHub repository.

Each common signal measures something real and misleads in a predictable way:

SignalWhat it tells youHow it misleads
Weekly downloads (project)Other people install it. Typos and hallucinated names sit near zero.Popular packages are the ones worth taking over. Counts measure installs, not review.
Dependent count (project)How much of the ecosystem relies on it.Spam farms publish packages that depend on each other to inflate it.
Stars and forks (project)Visibility of the linked repository.The repository field is set by the publisher and can point at someone else's popular repo.
Maintainers (project)How many accounts can publish.Says nothing about whose session is publishing today.
OpenSSF Scorecard (project)Repository practices: branch protection, code review, pinned workflows.Scores the source repository, not the tarball on the registry.
Known advisories (both)Disclosed vulnerabilities in this version.Malware advisories arrive after the malicious version is already gone.
Install hooks (version)Code that runs during install.Legitimate packages that fetch or build binaries need them. As of October 2026, 479 of esbuild's 482 versions declare one.
Added dependencies (version)New code entering your tree.Common in legitimate releases. Age and publisher of the new package matter more.
Publisher and provenance (version)Which identity and pipeline built the release.A compromised pipeline signs malware with valid provenance.

Two rows deserve more detail. As of October 2026, the OpenSSF Scorecard project scans more than 1.3 million repositories every week, and its own documentation is candid about what the numbers mean: "The checks themselves are heuristics; there are false positives and false negatives." Scorecard's README also notes that package names are used only to find a GitHub repository. Nothing it measures reads the artifact you install.

Maintainer signals have a second weakness: the account behind a name can be lost without any change you would see. A 2021 study of 1.63 million npm packages by Zahan et al. found 2,818 maintainer email addresses on expired domains, enough to take over 8,494 packages by registering the domain and resetting the password. That is a maintainer account takeover waiting to happen, and every project signal on those packages would still read healthy.

Which signals change when a trusted package turns malicious?

#

When a trusted package turns malicious, the signal that changes is almost never popularity or maintenance; it is a difference between the new tarball and the previous one. Which difference shows up depends on how the attacker got in, so we tested five real npm versions against five version-level signals.

The axios case is reconstructable from the registry, which keeps the publish time of every version, including the ones npm later removed:

Bash
$ npm view axios time --json | grep -E '"(1\.14\.[01]|0\.30\.[34])"'
  "0.30.3": "2026-02-18T17:19:20.081Z",
  "1.14.0": "2026-03-27T19:01:40.915Z",
  "1.14.1": "2026-03-31T00:21:58.168Z",
  "0.30.4": "2026-03-31T01:00:57.285Z",

$ npm view axios@1.14.1 version
npm error code E404
npm error 404 No match found for version 1.14.1

The attacker staged the dependency first. plain-crypto-js@4.2.0, a clean copy of crypto-js, appeared at 05:57:32 UTC on March 30. Version 4.2.1 added the postinstall dropper at 23:59:12, and Socket flagged it six and a half minutes later. axios@1.14.1 followed at 00:21:58 on March 31, and axios@0.30.4 39 minutes after that. The axios maintainer later confirmed both were "published to the npm registry through my compromised account" in his post-incident write-up.

The publisher history shows why the 1.x and 0.x releases look different to a metadata check. We pulled _npmUser and dist.attestations for every axios version from the live packument. From 1.13.4 on January 27, 2026 through 1.14.0, every 1.x release came from the GitHub Actions trusted publisher with SLSA provenance. The 0.x line had not moved: 0.30.3, published February 18, 2026, came from the maintainer's personal account with no attestation. So 1.14.1 broke an established publishing pattern, and a check that compares the publisher with the previous release flags it. 0.30.4 came from the same account as its predecessor, and the same check passes it.

Figure 2. Five malicious npm versions and one benign release, scored on five version-level signals. Only content analysis of what changed since the previous release caught all five. For both axios versions that meant following the added dependency into its code. The dependency-added signal also fired on the benign release.

Figure 2 and the table below hold the same data. Each row is one version; the last row is a benign control.

VersionPublisher changedInstall hook addedDependency addedLow popularityContent analysis of the change
axios@1.14.1Yes: trusted publisher to personal accountNoYes: plain-crypto-js, 18 h oldNoMalicious: dependency is a dropper
axios@0.30.4No: same personal account as 0.30.3NoYes: plain-crypto-jsNoMalicious: dependency is a dropper
debug@4.4.2No: same maintainer accountNoNone reportedNoMalicious: obfuscated wallet stealer in src/index.js
keyv@6.0.0No: same CI trusted publisher, valid provenanceYes: preinstall running setup.mjsNoNoMalicious: credential-stealing worm
plain-crypto-js@4.2.1Not applicable: attacker-owned from its first versionYes: postinstall running setup.jsNone reportedYes: package was hours oldMalicious: obfuscated dropper
axios@1.16.1 (benign)NoNoYes: https-proxy-agent, 12 years oldNoClean

Sources: Socket's write-ups on the axios compromise, the Qix compromise of debug and chalk, and the keyv and cacheable compromise; the debug maintainer's incident issue; and npm registry metadata queried on October 26, 2026.

No metadata signal caught more than two of the five. The publisher check caught one. The debug compromise in September 2025 is the hardest case for metadata: the attacker phished the maintainer, published from his real account, which had never used provenance, and added the payload to the package's own code, which ran in browsers rather than at install. The keyv compromise in August 2026 defeated provenance outright. Socket found that keyv@6.0.0 "shipped with a passing attestation because the legitimate release workflow built already-trojanized source." Provenance attests the build, not the source, and it is necessary but not sufficient.

What caught every case was malware detection that analyzes what the code does, applied to what changed. For the axios versions that meant following the added dependency into its tarball, where the obfuscated dropper sat. Socket flagged plain-crypto-js@4.2.1 at 00:05:41 UTC, 16 minutes before axios@1.14.1 existed. Our position is that this is the only assessment that holds up: analyze what is actually in the version, not what its metadata claims about it.

Advisories trail all of this. debug@4.4.2 eventually received a CVE, CVE-2025-59144, in an advisory published September 15, 2025, a week after npm had already removed the version. CVE scanning alone misses most supply chain attacks, and when malware does get a CVE, the record arrives after the window that mattered.

An npm package is not safe because it is popular. Popularity lowers one risk and raises another, so it is a weak signal in both directions.

Low popularity is the cheapest reliable sign of a typosquatting or slopsquatting attack, because a fake name has no users until an attacker gets one. Socket's Unpopular package alert, a medium-severity quality alert, fires when an npm or PyPI package falls under a 1,000-download threshold. AI coding tools add a new source of such names. A 2026 preprint, which we covered in July, found 53 hallucinated package names, 41 on PyPI and 12 on npm, that five frontier models all generated and that were still available to register as of April 2026. Any of those, registered by an attacker, would start life with zero downloads and trip the alert.

High popularity is what makes a package worth stealing. The packages in the Qix compromise collectively received 2 to 3 billion downloads per week. Popularity also gets manufactured. The TEA protocol spam campaign published packages that depend on each other to inflate dependent counts, and up to 150,000 of them were removed from npm in November 2025.

Treat low popularity as a reason to look closer. Never treat high popularity as a reason to look less.

Trivial packages carry dependency risk without code risk

#

A trivial package is one small enough to copy into your own code. Socket's Trivial Package alert, a medium-severity supply chain alert, flags packages with fewer than 10 lines of code. The code in such a package is rarely the risk. The dependency edge is: every package in your tree is a publisher account whose compromise reaches your build.

is-even shows the cost concretely. Its latest version, 1.0.0, is one line of logic, return !isOdd(i), and it still received 323,033 downloads in the week of October 16 to 22, 2026, according to the npm downloads API. Installing it on October 26, 2026 with npm 10.9.9 pulls a chain of four more packages:

Bash
$ npm install is-even@1.0.0 --ignore-scripts && npm ls --all
`-- is-even@1.0.0
  `-- is-odd@0.1.2
    `-- is-number@3.0.0
      `-- kind-of@3.2.2
        `-- is-buffer@1.1.6

That is five packages, 126 non-comment lines across their entry files, and four distinct maintainer accounts with publish rights, all to compute n % 2 === 0. Every one of those packages is a transitive dependency the person who chose is-even never reviewed. The chain is also stale: is-even pins is-odd to ^0.1.2, while is-odd's latest release is 3.0.1.

The canonical failure was left-pad, an 11-line package whose removal from npm in 2016 broke builds across the ecosystem. It had been downloaded nearly 2.5 million times in the month before, much of that as a transitive dependency of Babel, as covered in our post on the risks of trivial packages.

Inlining has a cost too. You take ownership of the edge cases: is-odd rejects non-integers and values beyond Number.MAX_SAFE_INTEGER, which a hand-written % 2 silently mishandles. Socket's own guidance is that the alert is often safe to ignore. Our call is to inline anything under about ten lines that you can read in full, and to keep a dependency only when it encodes knowledge you would get wrong, such as a standards-compliant parser.

What a package score can and cannot tell you

#

A package score compresses many project and version signals into one number per category. Socket's package scores rate every package from 0 to 100 in five categories: supply chain risk, vulnerability, quality, maintenance, and license. The published scoring model shows how they are built, and the mechanics matter for how you should use them.

Each category score is a weighted average of normalized metrics, such as download count, dependency count, version cadence, and alert counts by severity, raised to an exponent γ. Socket's documentation gives γ as roughly 1/2 + c0·log(lines of code) + c1·log(popularity), and says "larger or more widely used packages tend to have a smaller γ, which reduces the impact of negative metrics." A critical alert caps the weighted average at 0.25, which the documentation calls a soft cap:

Python
>>> 0.25 ** 0.8   # capped average, with γ = 0.8
0.32987697769322355

So with the documentation's example γ of 0.8, a version carrying a critical alert such as Known malware can still show about 33 out of 100 in a category, not zero. The documentation also warns that it "may not exactly represent the scoring system as deployed." That is the right behavior for a ranking tool and the wrong behavior for a gate. Scores are good for comparing candidate packages and for triage. Blocking should key on specific alerts, which is how Socket security policies work: each alert type gets an action, Block, Warn, Monitor, or Ignore, independent of the score.

The approaches people reach for compete on different ground:

ApproachWhat it readsCatchesMisses
npm auditLockfile versions against the advisory databaseDisclosed vulnerabilities, malware after it gets an advisoryAnything not yet reported
OpenSSF ScorecardThe source repositoryWeak repository practicesThe published tarball, compromised accounts
Package scoresAggregated project and version metricsUnhealthy, unpopular, or alert-heavy packages, for comparisonGate decisions: a critical alert caps a category near 33, not 0
Version diff with content analysisThe new tarball and its change from the previous releaseAll five compromises in Figure 2Nothing until the version exists; needs a scanner you trust
Manual code reviewWhatever the reviewer readsAnything, in principleTransitive releases nobody reviews; obfuscated payloads

Verdict: use project signals and package scores once, when deciding whether to adopt a package. Use npm audit and reachability-based triage for vulnerabilities in what you already run. Use version-level content analysis on every release that enters your tree, because that is the only layer that saw all five compromises above. Manual review does not scale past direct dependencies, and almost everything you install is transitive.

The assessment has to run where the install happens

#

Package risk assessment used to be a decision a developer made once, in a pull request. Now the version you assessed is rarely the version that runs. When axios@1.14.1 went live, the Twilio, Slack, and Contentful SDKs all declared axios: "^1.13.5", a range that resolved to the malicious release on the next fresh install. Nobody depending on those SDKs had chosen axios, let alone 1.14.1.

AI coding agents make the gap wider. They pick packages, add them, and run npx in sandboxes no reviewer watches. A lockfile freezes versions you already resolved; it does nothing for a dependency an agent adds today. So the assessment has to be callable by machines at the moment of resolution. As of June 2026, Socket MCP exposes package scoring to AI assistants through a public server that needs no API key, so an agent can score a dependency before it lands in a manifest. That helps with adoption. The harder half is still the version check at install, for every release, including the transitive ones nobody chose.

A package is never safe. A version is safe until the next one is published.