MCP Tools Reference
Every tool npmscan's MCP server exposes to AI agents — 23 in total — with its input schema and real request/response examples, so an agent (or the person configuring it) knows exactly what each one catches before relying on it.
https://npmscan.com/api/mcp. ChatGPT users can also install npmscan as an app for one-click setup. Claude Code users can install the npmscan Claude Code plugin, which bundles these tools with two audit skills.30 requests / 60s per IP, separate from the REST /api limit. Going over returns a rate-limit error telling you how many seconds to wait.- Risk caughtthe call succeeds and surfaces the exact risk the tool exists to catch
- Confirmed cleanthe call succeeds and comes back clean — confirmed safe, not just silent
- Edge caseboundary conditions: validation errors, not-found, or an ambiguous result
Registry metadata enriched with the signals a name or a README will never tell you: download/dependent counts, popularity and maintenance tiers, typosquat detection, and a direct vulnerable/clean verdict.
Each result carries weeklyDownloads/monthlyDownloads, dependentsCount, topPackagesRank (position in npmscan's own top-100k-by-downloads snapshot), and deterministic (not model-generated) popularityTier/maintenanceTier labels. A result matching the query with a 'very-low' popularityTier, zero dependents, or a 'stale' maintenanceTier is very likely abandoned or copy-paste, not a real contender — regardless of how relevant its name looks. possibleTyposquatOf is set when an obscure result's name is one typo away from a top-5,000 package (e.g. "raect" vs "react"); surface that explicitly rather than silently dropping the result.
| Name | Type | Required | Description |
|---|---|---|---|
| query | string | required | Search text, e.g. a package name or keywords (2–64 characters). |
| limit | number | optional | Max results to return. Default 20, max 50. |
Real name, high download counts, no typosquat match. This is what a normal, trustworthy result looks like.
search_packages({ "query": "react-router", "limit": 3 }){
"query": "react-router",
"total": 41,
"results": [
{
"name": "react-router",
"version": "6.23.1",
"description": "Declarative routing for React",
"publisher": "mjackson",
"weeklyDownloads": 11482031,
"monthlyDownloads": 48920104,
"dependentsCount": 8213,
"topPackagesRank": 87,
"popularityTier": "very-high",
"maintenanceTier": "active",
"possibleTyposquatOf": null,
"npmscanUrl": "https://npmscan.com/package/react-router"
}
/* ...2 more results */
]
}A one-letter-off name ("raect" vs. "react") with near-zero adoption ranks near the top of a naive text search — possibleTyposquatOf is the signal that separates it from a real contender.
search_packages({ "query": "raect" }){
"query": "raect",
"total": 1,
"results": [
{
"name": "raect",
"version": "0.0.1",
"weeklyDownloads": 4,
"dependentsCount": 0,
"topPackagesRank": null,
"popularityTier": "very-low",
"maintenanceTier": "stale",
"possibleTyposquatOf": { "name": "react", "rank": 3 },
"npmscanUrl": "https://npmscan.com/package/raect"
}
]
}The 2-character minimum is enforced locally; a 1-character query never reaches npm.
search_packages({ "query": "a" }){ "error": "\"query\" must be at least 2 characters" }Fetches latest version, install scripts (preinstall/postinstall are a key risk signal), maintainers, license, recent version history, weekly downloads, GitHub stars, TypeScript support, days since last publish, topPackagesRank, and a downloadTrend (growing/stable/declining vs. ~3 months ago). Also checks the LATEST version against OSV.dev — isLatestVersionVulnerable/highestSeverity are a direct answer, and popularityTier/maintenanceTier plus a plain-language maintenanceSummary tell you whether a gap since the last release means "stable and finished" or "abandoned." Read `deprecated` before recommending anything — it is a maintainer-set signal, not an inference.
| Name | Type | Required | Description |
|---|---|---|---|
| name | string | required | Exact npm package name, e.g. "lodash" or "@scope/name". |
get_package({ "name": "axios" }){
"name": "axios",
"latestVersion": "1.7.4",
"latestVersionInfo": {
"version": "1.7.4",
"scripts": { "postinstall": "" },
"deprecated": null
},
"weeklyDownloads": 58210442,
"githubStars": 105300,
"hasBuiltInTypes": true,
"daysSinceLastPublish": 12,
"popularityTier": "very-high",
"maintenanceTier": "active",
"maintenanceSummary": "Actively maintained: published 12 days ago, 58.2M weekly downloads, 105.3k GitHub stars.",
"downloadTrend": { "direction": "stable", "changePercent": 1.4 },
"possibleTyposquatOf": null,
"isLatestVersionVulnerable": false,
"highestSeverity": null,
"vulnerabilities": []
}`deprecated` comes straight from the npm registry, set by the package's own maintainers — request has carried this warning since 2020, pointing users at maintained HTTP clients instead.
request's own deprecation announcement on GitHubget_package({ "name": "request" }){
"name": "request",
"latestVersion": "2.88.2",
"latestVersionInfo": {
"version": "2.88.2",
"deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142"
},
"weeklyDownloads": 9800213,
"daysSinceLastPublish": 1730,
"popularityTier": "very-high",
"maintenanceTier": "stale",
"maintenanceSummary": "Deprecated by its maintainers; no publish in 1730 days despite still-high download counts from legacy installs.",
"isLatestVersionVulnerable": false,
"vulnerabilities": []
}get_package({ "name": "this-package-does-not-exist-anywhere" }){ "error": "Package \"this-package-does-not-exist-anywhere\" not found" }Fetches registry metadata for one exact version (dependencies, install scripts, tarball) and checks that exact version against OSV.dev. isVulnerable/highestSeverity are a direct answer, and each finding includes severity, a summary, and the fixedVersion to upgrade to. Use this instead of get_package whenever you already have an exact version — e.g. from a lockfile — rather than caring about the latest release.
| Name | Type | Required | Description |
|---|---|---|---|
| name | string | required | Exact npm package name. |
| version | string | required | Exact version string, e.g. "4.17.21". |
minimist@1.2.5 carries CVE-2021-44906, a prototype-pollution bug fixed in 1.2.6 — a real, well-documented CVE pinned to an exact historical version, used as a regression guard in npmscan's own test suite.
GHSA-xvch-5gv4-984h — Prototype Pollution in minimistget_package_version({ "name": "minimist", "version": "1.2.5" }){
"name": "minimist",
"version": "1.2.5",
"isVulnerable": true,
"highestSeverity": "CRITICAL",
"vulnerabilities": [
{
"id": "GHSA-xvch-5gv4-984h",
"summary": "Prototype Pollution in minimist",
"severity": "CRITICAL",
"aliases": ["CVE-2021-44906"],
"fixedVersion": "1.2.6",
"npmscanUrl": "https://npmscan.com/vulnerability/GHSA-xvch-5gv4-984h"
}
]
}Same package, the very next release — confirms the tool isn't just flagging the package by name.
get_package_version({ "name": "minimist", "version": "1.2.6" }){
"name": "minimist",
"version": "1.2.6",
"isVulnerable": false,
"highestSeverity": null,
"vulnerabilities": []
}get_package_version({ "name": "minimist", "version": "999.0.0" }){ "error": "Version \"999.0.0\" of package \"minimist\" not found" }Given an npm username, returns every package npm's own maintainer:<username> search index currently returns for that account (registry.npmjs.org's /-/v1/search — the public registry API has no dedicated 'list packages by maintainer' endpoint otherwise), plus currentlyMaintainsCount (still listed right now vs. already-revoked), totalWeeklyDownloads and totalDependents summed across every returned package. This does NOT run the publish-cluster / compromised-account detection check_maintainer_blast_radius does — use that tool instead for a security read on whether recent activity looks like a takeover. Natural pairing with check_maintainer_changes: once that tool names a maintainer on a package, call this with that username to see the rest of what they touch.
| Name | Type | Required | Description |
|---|---|---|---|
| maintainerUsername | string | required | Exact npm username, e.g. "sindresorhus" — as shown at npmjs.com/~username. |
sindresorhus maintains over a thousand packages — a large count on its own is not a red flag (see check_maintainer_blast_radius for the actual cluster-detection signal); this call just answers "what does this account touch and how big is its reach."
get_maintainer_profile({ "maintainerUsername": "sindresorhus" }){
"maintainerUsername": "sindresorhus",
"npmscanUrl": "https://npmscan.com/profile/sindresorhus",
"npmProfileUrl": "https://www.npmjs.com/~sindresorhus",
"avatarUrl": "https://npmscan.com/api/avatar/d36a92237c75c5337c17b60d90686bf9",
"totalPackagesFound": 1066,
"packagesReturned": 250,
"resultsTruncated": true,
"currentlyMaintainsCount": 250,
"totalWeeklyDownloads": 17893700000,
"totalDependents": 620500,
"packages": [
{
"name": "chalk",
"version": "5.3.0",
"lastPublished": "2023-07-01T09:14:07.000Z",
"weeklyDownloads": 289421153,
"dependentsCount": 84213,
"isCurrentMaintainer": true,
"npmscanUrl": "https://npmscan.com/package/chalk"
}
/* ...249 more */
],
"note": null
}npm's search index has no dedicated reverse lookup by design — a typo'd or since-abandoned username comes back as zero results, not a distinct "user not found" error.
get_maintainer_profile({ "maintainerUsername": "not-a-real-npm-user-xyz" }){
"maintainerUsername": "not-a-real-npm-user-xyz",
"npmscanUrl": "https://npmscan.com/profile/not-a-real-npm-user-xyz",
"npmProfileUrl": "https://www.npmjs.com/~not-a-real-npm-user-xyz",
"avatarUrl": null,
"totalPackagesFound": 0,
"packagesReturned": 0,
"resultsTruncated": false,
"currentlyMaintainsCount": 0,
"totalWeeklyDownloads": 0,
"totalDependents": 0,
"packages": [],
"note": "No packages found where \"not-a-real-npm-user-xyz\" is currently listed as a maintainer in npm's search index — check the spelling, or this account may not maintain any currently-published packages"
}Direct OSV.dev, GitHub Advisory, and NIST NVD lookups — single package, a whole dependency inventory in one call, or an exact CVE — each with a plain isVulnerable/found verdict instead of a raw advisory dump.
Returns isVulnerable and highestSeverity as a direct answer, plus each finding's severity, summary, CVE aliases, and fixedVersion. Omitting `version` returns every vulnerability ever recorded for the package across all versions — including ones long since fixed — so always pass an exact version when the question is "is the version I have installed safe."
| Name | Type | Required | Description |
|---|---|---|---|
| name | string | required | npm package name. |
| version | string | optional | Optional exact version to narrow results, e.g. one pinned in a lockfile. |
| ecosystem | string | optional | OSV ecosystem. Default "npm". |
query_vulnerabilities({ "name": "minimist", "version": "1.2.5" }){
"package": "minimist",
"version": "1.2.5",
"isVulnerable": true,
"highestSeverity": "CRITICAL",
"vulnerabilities": [
{ "id": "GHSA-xvch-5gv4-984h", "severity": "CRITICAL", "aliases": ["CVE-2021-44906"], "fixedVersion": "1.2.6", "npmscanUrl": "https://npmscan.com/vulnerability/GHSA-xvch-5gv4-984h" }
]
}query_vulnerabilities({ "name": "minimist", "version": "1.2.6" }){
"package": "minimist",
"version": "1.2.6",
"isVulnerable": false,
"highestSeverity": null,
"vulnerabilities": []
}Omitting version here would wrongly read as "minimist is currently unsafe" if you don't also check which version you actually have.
query_vulnerabilities({ "name": "minimist" }){
"package": "minimist",
"version": null,
"isVulnerable": true,
"highestSeverity": "CRITICAL",
"vulnerabilities": [
{ "id": "GHSA-xvch-5gv4-984h", "aliases": ["CVE-2021-44906"], "fixedVersion": "1.2.6" },
{ "id": "GHSA-vh95-rmgr-6w4m", "aliases": ["CVE-2020-7598"], "fixedVersion": "1.2.3" }
]
}Chunk-queries OSV behind the scenes so a large SBOM doesn't stop at the upstream 100-package batch limit. Pass either `packages` (explicit list) or `content` (raw manifest/lockfile/SBOM text) — never both. Each finding includes severity, summary, CVE aliases, and fixed version, so a full-inventory audit answer doesn't need a follow-up call per flagged package.
| Name | Type | Required | Description |
|---|---|---|---|
| packages | array | optional | Explicit {name, version?} list (1-1000 items). Use this OR content, not both. |
| content | string | optional | Raw package.json / package-lock.json / yarn.lock / pnpm-lock.yaml / CycloneDX JSON / SPDX JSON content. Use this OR packages, not both. |
| includeDevDependencies | boolean | optional | Only applies when content is a manifest/lockfile format that distinguishes dev dependencies. |
batch_query_vulnerabilities({
"packages": [
{ "name": "minimist", "version": "1.2.5" },
{ "name": "is-number", "version": "7.0.0" }
]
}){
"parsedPackageCount": 2,
"results": [
{
"package": { "name": "minimist", "version": "1.2.5" },
"vulnerabilityCount": 1,
"vulnerabilities": [
{ "id": "GHSA-xvch-5gv4-984h", "severity": "CRITICAL", "aliases": ["CVE-2021-44906"], "fixedVersion": "1.2.6" }
]
},
{ "package": { "name": "is-number", "version": "7.0.0" }, "vulnerabilityCount": 0, "vulnerabilities": [] }
],
"totalVulnerabilities": 1,
"packagesWithVulnerabilities": 1
}batch_query_vulnerabilities({
"packages": [
{ "name": "is-number", "version": "7.0.0" },
{ "name": "lodash", "version": "4.17.21" }
]
}){
"parsedPackageCount": 2,
"results": [
{ "package": { "name": "is-number", "version": "7.0.0" }, "vulnerabilityCount": 0, "vulnerabilities": [] },
{ "package": { "name": "lodash", "version": "4.17.21" }, "vulnerabilityCount": 0, "vulnerabilities": [] }
],
"totalVulnerabilities": 0,
"packagesWithVulnerabilities": 0
}The two input modes are mutually exclusive; this fails before any OSV call is made.
batch_query_vulnerabilities({
"packages": [{ "name": "lodash" }],
"content": "{ \"dependencies\": { \"lodash\": \"4.17.21\" } }"
}){ "error": "Provide either packages or content, not both" }Three disjoint sources via `type`: "reviewed" (default, GitHub curated CVE-backed advisories), "malware" (GitHub's own known-malicious-package advisories), or "osv" (OSV.dev's OpenSSF malicious-packages feed, which covers far more malicious npm packages than GitHub ever republishes under a GHSA id). Filter by severity, vulnerability category (XSS, SQL/NoSQL Injection, SSRF, Access Control, Code Injection, and more — reviewed only), an affected package name, or (reviewed/malware only) look up one exact advisory by GHSA or CVE ID. Cursor-paginated — pass a previous response's nextCursor back in as cursor for the next page.
| Name | Type | Required | Description |
|---|---|---|---|
| type | string | optional | "reviewed" | "malware" | "osv" (default reviewed). |
| severity | string | optional | "critical" | "high" | "medium" | "low" | "all" (default all; not applicable to malware/osv). |
| category | string | optional | Vulnerability category id, e.g. "xss", "sql-injection", "ssrf", "access-control" (reviewed only). |
| affects | string | optional | Filter to advisories affecting this npm package name. |
| ghsaId | string | optional | Exact lookup by GHSA ID (reviewed/malware only). |
| cveId | string | optional | Exact lookup by CVE ID (reviewed/malware only). |
| direction | string | optional | "asc" | "desc" by published date (default desc). |
| cursor | string | optional | Opaque pagination cursor from a previous response's nextCursor. |
The npmscan test suite asserts this directly: every advisory returned for category="xss" must carry the XSS category label, not just be loosely related.
get_latest_advisories({ "category": "xss", "severity": "high" }){
"severity": "high",
"category": "xss",
"direction": "desc",
"nextCursor": "MjAyNS0..._eyJpZCI6MTIzfQ",
"advisories": [
{
"id": "GHSA-xxxx-xxxx-xxxx",
"cve": "CVE-2025-xxxxx",
"summary": "Cross-site scripting (XSS) in a Markdown-to-HTML renderer via unsanitized image alt text",
"severity": "high",
"categories": ["xss"],
"packages": [{ "name": "example-markdown-renderer", "affectedRange": "< 3.2.1", "patchedVersion": "3.2.1" }]
}
/* ...29 more, per_page is 30 */
]
}get_latest_advisories({ "ghsaId": "GHSA-xvch-5gv4-984h" }){
"advisories": [
{ "id": "GHSA-xvch-5gv4-984h", "cve": "CVE-2021-44906", "summary": "Prototype Pollution in minimist", "severity": "critical" }
]
}A neutral, non-error case — both directions succeed, this is a sort-order sanity check, not a risk signal.
get_latest_advisories({ "direction": "asc" }){ "direction": "asc", "advisories": [ /* oldest reviewed advisory first */ ] }kev is non-null only for a confirmed, actively-exploited-in-the-wild CVE — treat that as an urgent-patch signal regardless of CVSS score. epss is the probability of exploitation in the next 30 days, a better prioritization signal than severity alone. If NVD has no record yet, a single-CVE lookup falls back to the raw MITRE record automatically (source: "mitre"). NVD is NOT npm-scoped — pass keywordSearch to narrow a search to a specific package/product.
| Name | Type | Required | Description |
|---|---|---|---|
| cveId | string | optional | Exact CVE ID for a single lookup, e.g. "CVE-2021-44228". Omit search filters when this is given. |
| keywordSearch | string | optional | Free-text search, e.g. a package or product name. |
| severity | string | optional | "CRITICAL" | "HIGH" | "MEDIUM" | "LOW" — CVSS v3 base severity filter. |
| cweId | string | optional | Filter by weakness type, e.g. "CWE-79". |
| publishedSince / publishedUntil | string | optional | YYYY-MM-DD date range, must be given together, capped at 120 days. |
| resultsPerPage | number | optional | Max results for a search (default 10, max 50). |
| startIndex | number | optional | Pagination offset for a search. |
CVE-2021-44228 (Log4Shell) has been on the CISA KEV list since it was created — the report's own remediation-ranking tests use it as a regression guard because it is a permanent, unambiguous "patch now" fact.
CVE-2021-44228 on the NIST NVDget_cve({ "cveId": "CVE-2021-44228" }){
"id": "CVE-2021-44228",
"vulnStatus": "Analyzed",
"cvss": { "version": "3.1", "baseScore": 10, "baseSeverity": "CRITICAL", "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H" },
"cwes": ["Improper Neutralization of Special Elements used in an Expression Language Statement"],
"source": "nvd",
"kev": {
"dateAdded": "2021-12-10",
"dueDate": "2021-12-24",
"requiredAction": "Apply updates per vendor instructions."
},
"epss": { "score": 0.94421, "percentile": 0.99976, "date": "2026-08-30" }
}get_cve({ "cveId": "CVE-2099-99999" }){ "cveId": "CVE-2099-99999", "found": false, "npmscanUrl": "https://npmscan.com/vulnerability/CVE-2099-99999" }Also rejected: malformed CVE IDs, and a publishedSince given without publishedUntil (or vice versa).
get_cve({}){ "error": "Provide cveId for an exact lookup, or at least one of keywordSearch/severity/cweId/publishedSince+publishedUntil to search" }Signals no advisory database carries: what an install script actually does, whether a publish's provenance matches its claimed source, whether maintainer control quietly changed hands, and whether a risk buried three levels deep in the dependency graph is reachable at all.
Checks for child_process use, network calls, access to sensitive paths/env (.ssh, .aws, .npmrc, *TOKEN/*KEY), obfuscation, remote binaries off trusted CDNs, writes to HOME, Discord/Telegram/Pastebin exfil endpoints, eval on decoded strings, chmod+exec of downloaded binaries, and CI-metadata telemetry — plus a typosquat name check. Returns a weighted totalScore and riskTier. This is a heuristic static scan, not proof of malice: it doesn't execute any code and doesn't check maintainer/ownership history (that's check_maintainer_changes).
| Name | Type | Required | Description |
|---|---|---|---|
| name | string | required | Exact npm package name. |
| version | string | optional | Exact version to analyze; omit to use the latest published version. |
The rubric-only unit test trips every content rule in a single script: child_process, network I/O, .ssh/.aws access, a remote .exe download, an eval() over base64-decoded input, and a Discord webhook exfil endpoint.
analyze_install_script({ "name": "example-malicious-pkg" }){
"name": "example-malicious-pkg",
"hasLifecycleScripts": true,
"lifecycleScripts": { "postinstall": "node scripts/setup.js" },
"findings": [
{ "rule": "child-process", "points": 15, "note": "Spawns a child process during install" },
{ "rule": "network-call", "points": 15, "note": "Makes a network request during install" },
{ "rule": "sensitive-path", "points": 20, "note": "Reads ~/.ssh or ~/.aws during install" },
{ "rule": "remote-binary", "points": 20, "note": "Downloads an executable from an untrusted host" },
{ "rule": "eval-decoded", "points": 25, "note": "Evaluates decoded (base64) content" },
{ "rule": "exfil-endpoint", "points": 20, "note": "Sends data to a Discord webhook" }
],
"totalScore": 115,
"riskTier": "critical"
}Real package, real call — lodash has no preinstall/install/postinstall/prepare entries at all, so there is nothing to scan.
analyze_install_script({ "name": "lodash" }){
"name": "lodash",
"version": "4.17.21",
"hasLifecycleScripts": false,
"lifecycleScripts": {},
"findings": [],
"totalScore": 0,
"riskTier": "none"
}cypress genuinely downloads a platform binary in postinstall — a real network call, so it scores nonzero. Nonzero doesn't mean malicious; it means "read the findings," which is the whole point of this tool over a bare boolean.
Cypress's own docs on its postinstall binary downloadanalyze_install_script({ "name": "cypress" }){
"name": "cypress",
"hasLifecycleScripts": true,
"lifecycleScripts": { "postinstall": "node index.js --exec install" },
"findings": [
{ "rule": "lifecycle-present", "points": 5, "note": "Package defines install-time lifecycle scripts" },
{ "rule": "network-call", "points": 15, "note": "Downloads a platform binary from a CDN during install" }
],
"totalScore": 20,
"riskTier": "low"
}`vulnerablePaths` directly answers "which of my dependencies pulled this in" by naming the root package(s) responsible. Only the "dependencies" field is followed (not dev/peer/optional); each range is resolved independently per branch, which does NOT emulate real node_modules hoisting/dedup — read results as "which vulnerable versions are reachable in the graph," not the exact installed layout. git/file/workspace/URL/npm-alias dependencies show up with a resolutionError instead of being silently skipped.
| Name | Type | Required | Description |
|---|---|---|---|
| packages | array | required | 1-15 root packages to expand from, e.g. a package.json's "dependencies". version accepts an exact version or a semver range; omitted = latest. |
| maxDepth | number | optional | Levels of transitive expansion beyond the roots (0 = roots only). Default 2, capped at 3. |
minimist@1.2.5 pulled in transitively via a root that depends on it — invisible to a direct-dependency-only scan, caught here.
GHSA-xvch-5gv4-984h — Prototype Pollution in minimistanalyze_transitive_dependencies({
"packages": [{ "name": "optimist", "version": "0.6.1" }],
"maxDepth": 2
}){
"summary": "Scanned 3 packages across 2 root(s); 1 vulnerable package found, pulled in by optimist.",
"vulnerablePaths": [
{
"name": "minimist",
"version": "1.2.5",
"highestSeverity": "CRITICAL",
"vulnerabilityCount": 1,
"pulledInBy": ["optimist"]
}
],
"totalPackagesScanned": 3,
"vulnerablePackageCount": 1,
"truncated": false
}express and body-parser both depend on debug — it appears once in the resolved graph, not twice, and nothing in it is vulnerable.
analyze_transitive_dependencies({
"packages": [{ "name": "express" }, { "name": "body-parser" }]
}){
"summary": "Scanned 14 packages across 2 root(s); 0 vulnerable packages found.",
"vulnerablePaths": [],
"totalPackagesScanned": 14,
"vulnerablePackageCount": 0,
"truncated": false
}Not a crash — surfaced per-node as a resolutionError so the rest of the graph still scans.
analyze_transitive_dependencies({ "packages": [{ "name": "some-fork", "version": "git+https://github.com/user/some-fork.git" }] }){
"nodes": [
{ "name": "some-fork", "version": null, "resolutionError": "Non-registry specifier (git URL) cannot be resolved", "isVulnerable": false }
],
"unresolvedCount": 1
}Three checks: (1) the SLSA build attestation's declared source repo/commit/builder against package.json's own repository field; (2) when this version lacks provenance, whether peer packages in the same npm scope or by the same maintainer(s) mostly have it — a package that's the odd one out in an org that always publishes from CI is a real anomaly; (3) package.json at the attested commit/tag in the source repo, diffed against the published tarball's own install scripts and dependencies — a script or dependency on npm that was never committed is exactly the stolen-npm-token publish pattern. Structural only — this does not cryptographically re-verify the Sigstore bundle.
| Name | Type | Required | Description |
|---|---|---|---|
| name | string | required | Exact npm package name. |
| version | string | optional | Exact version to check; omit to use the latest published version. |
check_package_provenance({ "name": "semver", "version": "7.6.3" }){
"name": "semver",
"version": "7.6.3",
"provenance": {
"hasProvenance": true,
"sourceRepository": "https://github.com/npm/node-semver",
"declaredRepository": "https://github.com/npm/node-semver",
"repositoryMatchesBuild": true
},
"sourceDiff": { "checked": true, "addedInstallScripts": [], "addedDependencies": [] },
"findings": [],
"totalScore": 0,
"riskTier": "none"
}A real, live-verified org-norm anomaly used as a regression guard in npmscan's own tests: this package lacks provenance while its @npmcli-scoped siblings consistently publish with it.
@npmcli/arborist on npm — check its provenance badge yourselfcheck_package_provenance({ "name": "@npmcli/arborist" }){
"name": "@npmcli/arborist",
"provenance": { "hasProvenance": false },
"peers": {
"orgKind": "scope",
"orgIdentifier": "@npmcli",
"peersChecked": 12,
"peersWithProvenance": 10,
"peerProvenanceRate": 0.83
},
"findings": [
{ "rule": "peer-provenance-anomaly", "points": 15, "note": "83% of packages in the @npmcli scope publish with provenance; this one does not" }
],
"totalScore": 15,
"riskTier": "low"
}lodash lacks provenance, but so does essentially everything published before npm introduced the feature — no peer-norm violation, so this scores clean rather than being flagged for a feature that didn't exist yet.
GitHub: "Introducing npm package provenance"check_package_provenance({ "name": "lodash" }){
"name": "lodash",
"provenance": { "hasProvenance": false },
"peers": { "orgKind": "maintainer", "peersChecked": 4, "peersWithProvenance": 0, "peerProvenanceRate": 0 },
"findings": [],
"totalScore": 0,
"riskTier": "none"
}Every published version carries the maintainers-list snapshot as it stood at that publish plus who actually ran `npm publish`, so diffing consecutive snapshots recovers exactly who was added or removed and when. Flags: a maintainer added recently who then published shortly after; a full sudden replacement of the maintainer list; a long-standing maintainer quietly dropped; or a maintainer-list change on npm not yet tied to any release — the more urgent case, since access changed hands but nothing has shipped with it yet. Also cross-checks whether the declared GitHub repository was transferred, archived, or went quiet.
| Name | Type | Required | Description |
|---|---|---|---|
| name | string | required | Exact npm package name. |
A regression guard in npmscan's own test suite — a maintainer account was compromised via phishing and used to publish malicious versions of chalk, debug, and other widely-used packages within hours of a maintainer-list change.
GHSA-2v46-p5h4-248w — Malware in chalkcheck_maintainer_changes({ "name": "chalk" }){
"name": "chalk",
"history": {
"changes": [
{ "version": "5.3.1", "publishedAt": "2025-09-08T15:20:00.000Z", "added": ["qix-"], "removed": [] }
]
},
"findings": [
{ "rule": "new-maintainer-published-quickly", "points": 35, "note": "A maintainer added shortly before this publish, on a package with years of prior stable history" }
],
"totalScore": 35,
"riskTier": "high"
}event-stream's real 2018 maintainer-handoff incident happened once, over seven years ago; with no recent churn, the lookback window means it correctly scores clean today rather than being permanently tainted.
npm's official post-mortem of the event-stream incidentcheck_maintainer_changes({ "name": "event-stream" }){
"name": "event-stream",
"history": { "changes": [], "note": "No maintainer changes within the lookback window" },
"findings": [],
"totalScore": 0,
"riskTier": "none"
}jade was rebranded and its GitHub repo transferred from jadejs/jade to pugjs/pug — a legitimate, documented rename that this tool distinguishes from a hostile takeover.
jade on npm — see the rename notice firsthandcheck_maintainer_changes({ "name": "jade" }){
"name": "jade",
"repository": {
"checked": true,
"declaredRepository": "https://github.com/jadejs/jade",
"currentFullName": "pugjs/pug",
"transferred": true,
"note": "Repository transferred/renamed; verify the new owner independently before treating this as benign"
},
"findings": [
{ "rule": "repository-transferred", "points": 10, "note": "Declared repository now resolves to a different owner/name" }
],
"riskTier": "low"
}npm's registry API has no dedicated 'list packages by maintainer' endpoint, so this uses the same maintainer:<username> qualifier npmjs.com's own site search supports. A large total package count is NOT itself a red flag — many legitimate maintainers publish hundreds of packages over a career — only a tight cluster (several packages' LATEST versions all published within a short window of each other) is scored, weighted up by how many packages it includes and by their combined weekly downloads/dependentsCount. A cluster where most packages share one npm scope (e.g. @docusaurus/*) is dampened — that's a project's own monorepo doing one coordinated release, not a compromised account spread across unrelated packages — and multiple distinct clusters on one account combine with diminishing returns rather than a plain sum, so an account with several independent, legitimate release clusters over its lifetime doesn't accumulate an unbounded score purely from being prolific. Each returned package's current maintainer list is cross-checked against the queried username, since access is often already revoked by the time this runs. Natural follow-up to check_maintainer_changes: once that tool flags a newly added or turned-over maintainer on one package, call this with that maintainer's username to see whether the same account touched other packages around the same time.
| Name | Type | Required | Description |
|---|---|---|---|
| maintainerUsername | string | required | Exact npm username, e.g. "sindresorhus" — as shown at npmjs.com/~username. |
The jaredwray npm account — owner of the keyv and cacheable package families — was compromised and used to republish a credential-stealing worm across the ecosystem (868+ downstream packages, 2B+ combined weekly downloads, per contemporaneous writeups from Wiz, Socket, JFrog, and others). Verified live during this tool's development: cache-manager, cacheable, @cacheable/net, flat-cache, file-entry-cache, and 3 scoped @cacheable/* packages were all published within 42 SECONDS of each other, and jaredwray remains listed as maintainer throughout (his own account was compromised, not handed to an outsider) — unlike qix's chalk/debug incident (see check_maintainer_changes), this cluster is still fully reproducible with a live call today, not just a historical reconstruction.
Wiz — keyv and cacheable npm Package Hijacked in Supply Chain Attackcheck_maintainer_blast_radius({ "maintainerUsername": "jaredwray" }){
"maintainerUsername": "jaredwray",
"avatarUrl": "https://npmscan.com/api/avatar/f08cf036a76de57f0deb190a24970e29",
"clusterWindowHours": 72,
"clusters": [
{
"windowStart": "2026-06-27T18:19:51.509Z",
"windowEnd": "2026-06-27T18:20:33.507Z",
"packageNames": ["@cacheable/utils", "@cacheable/memory", "@cacheable/node-cache", "cache-manager", "cacheable", "@cacheable/net", "flat-cache", "file-entry-cache"],
"packageCount": 8,
"combinedWeeklyDownloads": 343349809,
"stillCurrentMaintainerCount": 8
}
],
"findings": [
{ "rule": "tight-publish-cluster", "points": 65, "note": "8 packages published within a 72-hour window, at massive combined downstream scale" }
],
"totalScore": 65,
"riskTier": "high"
}sindresorhus maintains 1000+ real npm packages published over more than a decade — verified live during this tool's development, along with this exact cluster: parent-module, is-docker, locate-path, and find-up (all part of his own path/module-resolution utility family) were published within about 18 hours of each other. That is a real, legitimate coordinated release across related packages by their long-standing owner, not a compromise — which is exactly why every finding here is hedged as "worth verifying," never asserted as proof. A large total package count alone is still never itself the signal; only the cluster is, and its score stays modest (low/moderate) unless the packages involved carry serious combined downstream exposure.
find-up on npm — one of the four clustered packagescheck_maintainer_blast_radius({ "maintainerUsername": "sindresorhus" }){
"maintainerUsername": "sindresorhus",
"avatarUrl": "https://npmscan.com/api/avatar/d36a92237c75c5337c17b60d90686bf9",
"totalPackagesFound": 1066,
"packagesReturned": 250,
"resultsTruncated": true,
"clusters": [
{
"windowStart": "2025-09-15T07:20:45.338Z",
"windowEnd": "2025-09-16T01:13:57.112Z",
"packageNames": ["parent-module", "is-docker", "locate-path", "find-up"],
"packageCount": 4,
"stillCurrentMaintainerCount": 4
}
],
"findings": [
{ "rule": "tight-publish-cluster", "points": 24, "note": "4 packages published within 72h of each other — at least this many points from cluster size alone; combined downstream exposure across these popular utility packages may add more" }
],
"totalScore": 24,
"riskTier": "moderate"
}- ›A large, well-known org account can still surface a "high"/"critical"-scoring cluster from an entirely routine release — e.g. Facebook's "fb" account shows several clusters from Docusaurus and React Native lockstep releases (dozens of packages published together, near-100% under one npm scope). The same-scope dampener reduces those cluster scores substantially, and multiple such clusters combine with diminishing returns rather than summing linearly, but a single very large or high-exposure cluster can still cross into "critical" on its own — riskTier is a heuristic prompt to go verify with check_maintainer_changes, never a standalone verdict.
Pure local lookup, no network I/O. Pass the exact `rule` string(s) a prior finding already returned — batch up to 10 in one call to cover a whole findings array, duplicates resolving to the same playbook are deduplicated — or pass `id` to look up a specific playbook by slug directly. Each matched rule also gets its own short `situationNote` explaining specifically what that rule caught, so a batch of several different rules landing on the same playbook (e.g. six different analyze_install_script rules all resolving to supply-chain-compromise) never reads as identical, copy-pasted boilerplate — the note is fixed per rule (deterministic), not randomized, so the same input always returns the same output. An unrecognized rule or id is not an error: it comes back with `matched:false` and a `note`, since a low-severity or baseline-only finding (e.g. analyze_install_script's lifecycle-present, which just means a lifecycle script exists at all) legitimately has no dedicated playbook.
| Name | Type | Required | Description |
|---|---|---|---|
| rules | array | optional | 1-10 exact `rule` values copied from findings already returned by analyze_install_script/check_maintainer_changes/check_package_provenance. At least one of rules/id is required. |
| id | string | optional | A playbook slug to look up directly, e.g. "postinstall-binary" — see /docs/playbooks. At least one of rules/id is required. |
The `rule` value is passed through unchanged from a prior check_maintainer_changes call — this tool never re-derives it from prose. situationNote cites the real September 2025 chalk/debug "qix" compromise, the same incident check_maintainer_changes' own catalog example above is a regression guard for.
get_remediation_playbook({ "rules": ["full-maintainer-turnover"] }){
"matches": [
{
"rule": "full-maintainer-turnover",
"requestedId": null,
"matched": true,
"playbookId": "maintainer-change-flagged",
"situationNote": "None of the maintainers who held access before the lookback window remain at all — a complete, sudden handoff like this is one of the strongest indicators of a hostile takeover, not a routine transition.",
"note": "Matched to the \"maintainer-change-flagged\" playbook."
}
],
"playbooks": [
{
"id": "maintainer-change-flagged",
"title": "Maintainer change flagged",
"severity": "high",
"steps": [
{ "text": "Freeze to last known‑good version; audit diffs of latest release.", "why": "Stabilizes while you verify new ownership." },
{ "text": "Check repo activity and communication; look for transparency.", "why": "Legitimate handovers are usually documented." },
{ "text": "Require two‑person review for first re‑adopted versions.", "why": "Adds oversight during the riskiest period." }
],
"references": [
{ "label": "npm's post-mortem of the event-stream incident", "url": "https://blog.npmjs.org/post/180565383195/details-about-the-event-stream-incident", "kind": "incident" }
],
"preventionTips": [
"Alert on any maintainer-list change or repository transfer/archival for production dependencies, not just at upgrade time.",
"Require two-person review for the first version published under a changed maintainer list.",
"Prefer packages published via npm trusted publishing (OIDC) over long-lived personal publish tokens where the option exists."
],
"npmscanUrl": "https://npmscan.com/docs/playbooks#maintainer-change-flagged"
}
]
}obfuscation and exfil-hosts are two different analyze_install_script findings that both resolve to supply-chain-compromise — the playbook itself is returned once, not twice, but each match keeps its own specific situationNote rather than sharing one generic sentence.
get_remediation_playbook({ "rules": ["obfuscation", "exfil-hosts"] }){
"matches": [
{
"rule": "obfuscation",
"playbookId": "supply-chain-compromise",
"situationNote": "The install script content is obfuscated (hex-encoded identifiers, large base64 blobs) — legitimate build tooling rarely needs to hide what it is doing from a reader."
},
{
"rule": "exfil-hosts",
"playbookId": "supply-chain-compromise",
"situationNote": "The install script contacts a known exfiltration-style endpoint (Discord/Telegram webhook, Pastebin, webhook.site) — a real, verified 2026 npm incident used exactly this pattern, a preinstall script posting the host's username and hostname to a webhook.site collector on every install."
}
],
"playbooks": [
{ "id": "supply-chain-compromise", "title": "Supply‑chain compromise", "severity": "critical" }
]
}get_remediation_playbook({ "id": "postinstall-binary" }){
"matches": [
{ "rule": null, "requestedId": "postinstall-binary", "matched": true, "playbookId": "postinstall-binary", "situationNote": null, "note": "Matched playbook id \"postinstall-binary\" directly." }
],
"playbooks": [
{
"id": "postinstall-binary",
"title": "Postinstall downloads a binary",
"severity": "critical",
"references": [
{ "label": "GHSA-pjwm-rvh2-c87w — Embedded malware in ua-parser-js", "url": "https://github.com/advisories/GHSA-pjwm-rvh2-c87w", "kind": "incident" }
]
}
]
}"lifecycle-present" just means analyze_install_script found a preinstall/install/postinstall/prepare script at all — not evidence of risk on its own, so it correctly comes back unmatched rather than forcing an unrelated playbook.
get_remediation_playbook({ "rules": ["lifecycle-present"] }){
"matches": [
{ "rule": "lifecycle-present", "requestedId": null, "matched": false, "playbookId": null, "situationNote": null, "note": "No dedicated playbook for rule \"lifecycle-present\" — likely a baseline/informational finding, not evidence of risk on its own." }
],
"playbooks": []
}License-policy enforcement, before/after dependency diffing for PR review, ranking a pile of already-found vulnerabilities by what to actually fix first, simulating whether a suggested fix version is a safe bump or a breaking one, turning a "don't use this" warning into a concrete replacement shortlist, and exporting the whole thing as a spec-valid CycloneDX/SPDX SBOM for downstream tooling.
Classifies every license into permissive/weak-copyleft/copyleft/network-copyleft/proprietary/public-domain/unknown, and understands simple SPDX expressions: "(MIT OR GPL-3.0)" is compliant if either side is permitted, "MIT AND Apache-2.0" requires both sides to pass. With no policy given, only copyleft/network-copyleft/proprietary (GPL/AGPL/UNLICENSED) are violations; policy.deny always wins over policy.allow. This reads only the registry-declared license field, not LICENSE file contents.
| Name | Type | Required | Description |
|---|---|---|---|
| packages | array | required | 1-100 {name, version?} packages to check. version accepts an exact version or a semver range; omitted = latest. |
| policy | object | optional | { allow?: string[], deny?: string[] } — SPDX ids, family prefixes (e.g. "GPL"), or category names. Omit for the default policy. |
One call mixing genuinely copyleft-licensed packages with permissively-licensed ones under the default policy.
check_license_compliance({
"packages": [
{ "name": "graphviz" },
{ "name": "lightningcss" },
{ "name": "lodash" }
]
}){
"policy": { "mode": "default", "allow": [], "deny": [] },
"summary": "1 of 3 packages violate the default policy (copyleft/network-copyleft/proprietary).",
"results": [
{ "package": { "name": "graphviz" }, "rawLicense": "GPL-3.0-or-later", "category": "copyleft", "isCompliant": false, "violation": { "rule": "default-copyleft", "text": "GPL-3.0-or-later is copyleft" } },
{ "package": { "name": "lightningcss" }, "rawLicense": "MPL-2.0", "category": "weak-copyleft", "isCompliant": true },
{ "package": { "name": "lodash" }, "rawLicense": "MIT", "category": "permissive", "isCompliant": true }
],
"totalPackages": 3,
"compliantCount": 2,
"violationCount": 1
}policy.deny lists GPL-3.0, but the package is dual-licensed — the MIT side is legally sufficient, so it is not a violation.
check_license_compliance({
"packages": [{ "name": "expand-template" }],
"policy": { "deny": ["GPL-3.0"] }
}){
"results": [
{ "package": { "name": "expand-template" }, "rawLicense": "(MIT OR WTFPL)", "category": "permissive", "isCompliant": true, "needsReview": false }
],
"violationCount": 0
}The same package is compliant under the default policy but becomes a violation once policy.allow is narrowed to ["MIT"] — unknown is never auto-compliant against an allow-list.
check_license_compliance({
"packages": [{ "name": "ckeditor4" }],
"policy": { "allow": ["MIT"] }
}){
"results": [
{ "package": { "name": "ckeditor4" }, "rawLicense": "(GPL-2.0-or-later OR LGPL-2.1-or-later OR MPL-1.1)", "category": "mixed", "isCompliant": false, "needsReview": true }
],
"needsReviewCount": 1
}`installScriptIntroduced` is the highest-signal field: a routine-looking patch bump that quietly adds a postinstall is exactly the shape of a compromised-maintainer supply-chain attack. `vulnerabilityDelta` reports introduced/fixed/still-vulnerable/still-clean per changed package rather than a bare isVulnerable flag. Only direct dependencies are diffed for package.json/package-lock/pnpm-lock; yarn.lock has no direct/transitive distinction so its side covers every resolved package in the file.
| Name | Type | Required | Description |
|---|---|---|---|
| before | string | required | Raw "before" snapshot content — format auto-detected. |
| after | string | required | Raw "after" snapshot content — may be a different format than before. |
diff_dependencies({
"before": "{ \"dependencies\": { \"minimist\": \"1.2.5\" } }",
"after": "{ \"dependencies\": { \"minimist\": \"1.2.6\" } }"
}){
"summary": "1 package changed. 1 vulnerability fixed.",
"changed": [
{
"name": "minimist",
"beforeVersion": "1.2.5",
"afterVersion": "1.2.6",
"changeType": "upgrade",
"isVulnerable": false,
"vulnerabilityDelta": "fixed"
}
],
"flaggedCount": 0
}Same two versions, swapped — the tool doesn't just check the final version's status, it reports the direction of the change.
diff_dependencies({
"before": "{ \"dependencies\": { \"minimist\": \"1.2.6\" } }",
"after": "{ \"dependencies\": { \"minimist\": \"1.2.5\" } }"
}){
"summary": "1 package changed. 1 vulnerability introduced by this change.",
"changed": [
{
"name": "minimist",
"beforeVersion": "1.2.6",
"afterVersion": "1.2.5",
"changeType": "downgrade",
"isVulnerable": true,
"highestSeverity": "CRITICAL",
"vulnerabilityDelta": "introduced"
}
],
"flaggedCount": 1
}package.json declares "^4.17.21" and package-lock.json pins "4.17.21" for the same package — resolved to the same version, so it is correctly left out of "changed" instead of being reported as a false diff.
diff_dependencies({
"before": "{ \"dependencies\": { \"lodash\": \"^4.17.21\" } }",
"after": "{ \"packages\": { \"node_modules/lodash\": { \"version\": \"4.17.21\" } } }"
}){
"beforeFormat": "package.json",
"afterFormat": "npm-lock",
"comparisonNote": "before is package.json and after is npm-lock — version strings are compared after resolving both to exact registry versions, not as raw text",
"changed": [],
"totalChanged": 0
}KEV status (confirmed active exploitation) is an automatic top-priority override; EPSS (30-day exploitation probability) is the primary ranking signal since it measures likelihood, not just impact; severity is a fallback, most useful for a GHSA finding with no CVE alias. Pass in findings other tools already returned (batch_query_vulnerabilities, analyze_transitive_dependencies, query_vulnerabilities) — a CVE ID shared by multiple findings in the same call is looked up once.
| Name | Type | Required | Description |
|---|---|---|---|
| findings | array | required | 1-200 {packageName, cveId?, severity?, currentVersion?, fixedVersion?, advisoryId?} findings to rank. |
CVE-2021-44228 (Log4Shell) is a long-standing CISA KEV entry — used as a regression guard in npmscan's own tests because its KEV status is a permanent, unambiguous fact.
CVE-2021-44228 on the NIST NVDprioritize_remediation({
"findings": [
{ "packageName": "vulnerable-log4j-wrapper", "cveId": "CVE-2021-44228", "severity": "CRITICAL" },
{ "packageName": "is-number", "severity": "LOW" }
]
}){
"totalFindings": 2,
"summary": { "patchNow": 1, "patchSoon": 0, "scheduled": 0, "monitor": 1, "kevListedCount": 1 },
"ranked": [
{
"rank": 1,
"packageName": "vulnerable-log4j-wrapper",
"cveId": "CVE-2021-44228",
"kev": { "dateAdded": "2021-12-10", "requiredAction": "Apply updates per vendor instructions." },
"epss": { "score": 0.94421, "percentile": 0.99976 },
"tier": "patch-now",
"reason": "Confirmed actively exploited (CISA KEV) — patch immediately regardless of EPSS/severity."
},
{ "rank": 2, "packageName": "is-number", "cveId": null, "kev": null, "tier": "monitor" }
]
}prioritize_remediation({
"findings": [
{ "packageName": "pkg-a", "cveId": "CVE-2021-44906", "severity": "CRITICAL" },
{ "packageName": "pkg-b", "cveId": "CVE-2021-44906", "severity": "CRITICAL" }
]
}){
"totalFindings": 2,
"uniqueCveCount": 1,
"ranked": [
{ "rank": 1, "packageName": "pkg-a", "cveId": "CVE-2021-44906" },
{ "rank": 2, "packageName": "pkg-b", "cveId": "CVE-2021-44906" }
]
}Also rejected: an empty findings array, and a malformed cveId that doesn't match the CVE-YYYY-NNNN pattern.
prioritize_remediation({ "findings": [ /* 201 items */ ] }){ "error": "findings: Array must contain at most 200 element(s)" }Classifies the jump by semver (major/minor/patch/prerelease), treating a minor bump between two pre-1.0 (0.x) versions as breaking-risk per semver's own "the API isn't stable yet" convention, and flags skipping over multiple major versions in one jump (e.g. 2.x -> 5.x) as needing a per-major changelog review. Beyond semver it checks the registry for a newly-deprecated target version, a newly-introduced preinstall/install/postinstall/prepare lifecycle script, and a tightened engines.node requirement, then batch-checks both versions against OSV.dev and reports vulnerabilityDelta (introduced/fixed/still-vulnerable/still-clean) — catching a suggested "fix" version that only clears one of several open CVEs. Does not fetch changelogs or diff the target tarball's source — a fast, deterministic pre-check, not a substitute for reading release notes on a flagged major bump.
| Name | Type | Required | Description |
|---|---|---|---|
| packageName | string | required | Exact npm package name, e.g. "lodash" or "@scope/name". |
| currentVersion | string | required | Currently installed version — exact version, semver range, or dist-tag. |
| targetVersion | string | optional | Version to simulate upgrading to — exact version, range, or dist-tag. Omit for the registry's "latest" dist-tag. |
lodash 3.x -> 4.x is a real major rewrite (many top-level function signatures changed) — the semver bump alone is enough to flag it, independent of vulnerability status.
simulate_dependency_upgrade({
"packageName": "lodash",
"currentVersion": "3.10.1",
"targetVersion": "4.17.21"
}){
"resolvedCurrentVersion": "3.10.1",
"resolvedTargetVersion": "4.17.21",
"direction": "upgrade",
"semverBump": "major",
"isBreakingBySemver": true,
"majorVersionsSkipped": 0,
"vulnerabilityDelta": "still-clean",
"riskTier": "breaking-change-likely",
"reasons": ["Major version bump — semver signals this release is allowed to contain breaking API changes."]
}minimist 1.2.5 -> 1.2.6 is the same trusted CRITICAL prototype-pollution fixture other npmscan tests use — a clean patch bump that actually fixes the vulnerability.
CVE-2021-44906 on the NIST NVDsimulate_dependency_upgrade({
"packageName": "minimist",
"currentVersion": "1.2.5",
"targetVersion": "1.2.6"
}){
"resolvedCurrentVersion": "1.2.5",
"resolvedTargetVersion": "1.2.6",
"direction": "upgrade",
"semverBump": "patch",
"isBreakingBySemver": false,
"installScriptIntroduced": false,
"currentIsVulnerable": true,
"targetIsVulnerable": false,
"vulnerabilityDelta": "fixed",
"riskTier": "safe",
"reasons": ["Patch version bump — semver signals a backward-compatible bug fix.", "Target version resolves a known vulnerability present in the current version."]
}Only a nonexistent package name is rejected outright; an unsatisfiable version spec against a real package comes back as a 200 with resolvedTargetVersion: null and an explanatory note.
simulate_dependency_upgrade({
"packageName": "lodash",
"currentVersion": "4.17.21",
"targetVersion": "^99.0.0"
}){
"resolvedCurrentVersion": "4.17.21",
"resolvedTargetVersion": null,
"targetVersionNote": "No published version of \"lodash\" satisfies \"^99.0.0\"",
"direction": "unresolved",
"riskTier": "unknown"
}Checks the source package's own health first (deprecation, latest-version OSV verdict, popularity/maintenance tiers, typosquat), then combines maintainer-provided deprecation hints with deterministic npm search-based category matching. Filters out typosquats and weak/stale contenders, and returns plain-language whySuggested notes per candidate. nonPackageAlternatives surfaces a built-in-language alternative (e.g. String.prototype.padStart()) instead of forcing a package suggestion when one isn't warranted.
| Name | Type | Required | Description |
|---|---|---|---|
| name | string | required | Exact npm package name, e.g. "request" or "node-sass". |
| reason | string | optional | "deprecated" | "vulnerable" | "abandoned" | "typosquat" | "general" — biases filtering/ranking. |
| limit | number | optional | Max suggestions to return. Default 5, max 10. |
node-sass's own deprecation message names its replacement — the tool reads that hint rather than guessing from category search alone.
node-sass on npm — read the deprecation notice yourselfsuggest_alternative({ "name": "node-sass" }){
"source": { "name": "node-sass", "deprecated": "node-sass is deprecated. Please use dart-sass instead.", "popularityTier": "high", "maintenanceTier": "stale" },
"reason": "deprecated",
"confidence": "high",
"suggestions": [
{ "name": "sass", "deprecated": null, "isLatestVersionVulnerable": false, "whySuggested": "Named directly in node-sass's own deprecation notice; actively maintained." },
{ "name": "sass-embedded", "deprecated": null, "whySuggested": "Named directly in node-sass's own deprecation notice; faster native binding." }
]
}request-promise's deprecation message is prose without an explicit replacement package name; the tool falls back to category matching rather than inventing one.
request-promise on npm — read the deprecation notice yourselfsuggest_alternative({ "name": "request-promise" }){
"source": { "name": "request-promise", "deprecated": "request-promise has been deprecated because request has been deprecated." },
"reason": "deprecated",
"confidence": "medium",
"suggestions": [
{ "name": "got", "whySuggested": "Same HTTP-client category, actively maintained, no open vulnerabilities." },
{ "name": "axios", "whySuggested": "Same HTTP-client category, actively maintained, no open vulnerabilities." }
]
}left-pad's functionality is now a JavaScript built-in — nonPackageAlternatives surfaces that instead of padding the suggestions list with unrelated packages.
String.prototype.padStart() on MDNsuggest_alternative({ "name": "left-pad" }){
"source": { "name": "left-pad" },
"reason": "general",
"nonPackageAlternatives": ["String.prototype.padStart()"],
"suggestions": []
}Each candidate gets downloads + trend, popularityTier/maintenanceTier, GitHub stars, TypeScript support, license, deprecated status, latest-version vulnerability status, a lightweight installScriptRisk signal (scans lifecycle script command strings for known red flags — does NOT fetch the tarball; call analyze_install_script on a specific candidate for that deeper scan), and installSize (the candidate's own dist.unpackedSize plus a transitive rollup — dist.unpackedSize summed across its resolved dependency tree, walked up to depth 2 / 60 nodes per candidate; a small package can still drag in a large tree, so `installSize.transitive.transitiveUnpackedSize` is often the more useful number than the package's own size). `differentiators` names which candidates stand out on each dimension, including the smallest/largest install footprint. `recommendation.pick` is chosen from a deterministic weighted score across popularity, maintenance, deprecation, vulnerabilities, typosquat flag, install-script risk, TS support, and GitHub stars (install size is reported but not scored) — never a deprecated or typosquat-flagged candidate — with `rationale` explaining why and `confidence` reflecting the score gap to the runner-up. A name that can't be resolved still appears in `candidates` with `found:false` and `resolutionError` set rather than failing the whole call.
| Name | Type | Required | Description |
|---|---|---|---|
| packages | string[] | required | 2-5 exact npm package names to compare, e.g. ["axios", "got", "node-fetch"]. |
All three candidates resolve cleanly; the pick is driven by the same popularity/maintenance/vulnerability signals get_package already exposes, not name recognition. installSize surfaces a signal none of those do: node-fetch's own package is a fraction of axios's size, but its transitive tree is over 4x larger — invisible from downloads/stars alone.
compare_packages({ "packages": ["axios", "got", "node-fetch"] }){
"candidates": [
{ "name": "axios", "found": true, "weeklyDownloads": 65000000, "popularityTier": "very-high", "maintenanceTier": "active", "hasBuiltInTypes": true, "isLatestVersionVulnerable": false, "installScriptRisk": { "hasLifecycleScripts": false, "riskTier": "none" }, "installSize": { "unpackedSize": 1983343, "transitive": { "transitiveUnpackedSize": 2271546, "transitiveDependencyCount": 12, "sizeUnknownCount": 1, "truncated": false } }, "score": 41 },
{ "name": "got", "found": true, "weeklyDownloads": 26000000, "popularityTier": "very-high", "maintenanceTier": "active", "hasBuiltInTypes": true, "isLatestVersionVulnerable": false, "installScriptRisk": { "hasLifecycleScripts": false, "riskTier": "none" }, "installSize": { "unpackedSize": 448958, "transitive": { "transitiveUnpackedSize": 1553040, "transitiveDependencyCount": 19, "sizeUnknownCount": 0, "truncated": false } }, "score": 26 },
{ "name": "node-fetch", "found": true, "weeklyDownloads": 45000000, "popularityTier": "very-high", "maintenanceTier": "aging", "hasBuiltInTypes": true, "isLatestVersionVulnerable": false, "installScriptRisk": { "hasLifecycleScripts": false, "riskTier": "none" }, "installSize": { "unpackedSize": 107319, "transitive": { "transitiveUnpackedSize": 9236710, "transitiveDependencyCount": 6, "sizeUnknownCount": 0, "truncated": false } }, "score": 20 }
],
"differentiators": {
"mostDownloads": "axios",
"hasTypeScriptSupport": ["axios", "got", "node-fetch"],
"hasKnownVulnerabilities": [],
"deprecated": [],
"possibleTyposquat": [],
"installScriptRiskFlagged": [],
"smallestInstallSize": "got",
"largestInstallSize": "node-fetch"
},
"recommendation": {
"pick": "axios",
"runnerUp": "got",
"rationale": "axios: widely used (65,000,000 weekly downloads); actively maintained (published 12d ago); ships built-in TypeScript types; no known vulnerabilities in its latest version.",
"confidence": "high"
}
}request is real and deprecated; it still appears in candidates with its own scores so the caller can see why it lost, but recommendation.pick skips it entirely.
compare_packages({ "packages": ["axios", "request", "node-fetch"] }){
"candidates": [
{ "name": "axios", "found": true, "deprecated": null, "score": 41 },
{ "name": "request", "found": true, "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", "maintenanceTier": "stale", "score": -34 },
{ "name": "node-fetch", "found": true, "deprecated": null, "score": 20 }
],
"differentiators": { "deprecated": ["request"], "possibleTyposquat": [], "hasKnownVulnerabilities": [] },
"recommendation": {
"pick": "axios",
"runnerUp": "node-fetch",
"rationale": "axios: widely used (65,000,000 weekly downloads); actively maintained (published 12d ago); ships built-in TypeScript types; no known vulnerabilities in its latest version. 1 candidate excluded from consideration (deprecated or a possible typosquat).",
"confidence": "high"
}
}A typo or unpublished name doesn't abort the whole call — it comes back as one candidate with found:false and a resolutionError, while the other candidates are still fully scored and compared.
compare_packages({ "packages": ["axios", "got", "definitely-not-a-real-package-xyz"] }){
"candidates": [
{ "name": "axios", "found": true, "score": 41 },
{ "name": "got", "found": true, "score": 30 },
{ "name": "definitely-not-a-real-package-xyz", "found": false, "resolutionError": "Package \"definitely-not-a-real-package-xyz\" not found", "installScriptRisk": null, "score": null }
],
"recommendation": { "pick": "axios", "runnerUp": "got", "confidence": "medium" }
}Tries pnpm-lock.yaml, then package-lock.json, then yarn.lock, in that order — the first one found is used for exact resolved versions; falls back to package.json alone (ranges resolved against the registry) when none exist. A monorepo is detected automatically from package.json#workspaces, Yarn's {packages:[...]} form, or pnpm-workspace.yaml: pnpm-lock.yaml and yarn.lock already record every workspace member's dependencies directly, and for a package-lock.json or no-lockfile repo this additionally lists the repo's file tree, resolves the declared glob patterns to member directories, and merges each member's dependencies into the audit (capped at 50 member packages) — without this, a monorepo audited via its root manifest alone would only see the root's own dev tooling, silently missing every workspace member's real dependencies. Every direct dependency (up to 100 per call, across the root and any merged workspace members) gets a vulnerability check, a license-compliance verdict, and a tarball-free install-script signal; up to 10 packages that actually declare a lifecycle script additionally get the full tarball-fetching deep scan. Any package that comes back vulnerable at high/critical severity, a possible typosquat, or deprecated additionally gets a maintainer-history check and a publish-provenance check (the same ones check_maintainer_changes/check_package_provenance expose individually) — up to 5 such packages per call, named in ownershipCheckNote if more qualified. This is the most expensive tool in the suite — avoid calling it in a tight loop across many repos.
| Name | Type | Required | Description |
|---|---|---|---|
| url | string | required | GitHub repository URL, e.g. "https://github.com/owner/repo". |
| ref | string | optional | Branch, tag, or commit SHA to audit; omit to use the repository's default branch. |
| includeDevDependencies | boolean | optional | Include package.json devDependencies (root and, for a monorepo, every merged workspace member). Default false; ignored when a pnpm/yarn lockfile is used instead. |
| policy | object | optional | { allow?: string[], deny?: string[] } — same shape as check_license_compliance. Omit for the default policy (only copyleft/network-copyleft/proprietary are violations). |
One of express's own dependencies declares a lifecycle script — it gets the full tarball-fetching deep scan rather than just the cheap tier-1 signal.
audit_github_repository({ "url": "https://github.com/expressjs/express" }){
"summary": "Audited 28 dependencies from expressjs/express: 0 with known vulnerabilities, 0 license violation(s), 3 flagged install script(s).",
"lockfilePath": null,
"inputFormat": "package.json",
"isMonorepo": false,
"findings": [
{
"name": "content-disposition",
"resolvedVersion": "3.0.0",
"isVulnerable": false,
"rawLicense": "MIT",
"isLicenseCompliant": true,
"hasLifecycleScripts": true,
"installScriptRiskTier": "low",
"installScriptScanScope": "deep-tarball-scan"
}
],
"totalPackages": 28,
"installScriptFlaggedCount": 3,
"deepScannedCount": 3
}npm/cli itself is an npm-workspaces monorepo with a committed package-lock.json (no pnpm-lock.yaml/yarn.lock). "@npmcli/query" is a real dependency of its "arborist" workspace member, absent from the root manifest's own dependency list — it only shows up in findings because member enumeration ran.
npm/cli's package.json — see the "workspaces" fieldaudit_github_repository({ "url": "https://github.com/npm/cli" }){
"lockfilePath": "package-lock.json",
"isMonorepo": true,
"workspacePatterns": ["docs", "smoke-tests", "mock-globals", "mock-registry", "workspaces/*"],
"workspacePackageCount": 16,
"workspaceNote": "Monorepo detected (docs, smoke-tests, mock-globals, mock-registry, workspaces/*) — merged 16 workspace package(s) in addition to the root manifest.",
"findings": [
{ "name": "@npmcli/query", "resolvedVersion": "4.0.1", "isVulnerable": false }
],
"totalPackages": 84
}audit_github_repository({ "url": "https://github.com/github/gitignore" }){ "error": "No package.json found in \"github/gitignore\" at ref \"main\"" }- ›Shares its business logic with POST /api/analysis/audit-github-repository.
- ›Ownership-risk fields (maintainerRiskTier/maintainerFindings, provenanceRiskTier/provenanceFindings) are only populated for findings with ownershipRiskChecked=true — everything else keeps them null even when ownershipRiskEligible is true, which just means the package matched a trigger condition but fell past the per-call cap.
- ›pnpm-lock.yaml and yarn.lock already cover every workspace member on their own (pnpm's lockfile unions every "importers" entry; yarn.lock has no root/member distinction at all) — workspacePackageCount stays 0 for those even when isMonorepo is true, and workspaceNote explains why.
CycloneDX gets a top-level vulnerabilities[] array (one entry per unique advisory id, with every affected component listed in affects[] rather than duplicating the same advisory per component) and per-component licenses[]. SPDX 2.3 has no vulnerabilities array of its own, so each finding becomes a packages[].externalRefs[] entry (referenceCategory "SECURITY", referenceType "advisory") instead, alongside the real native licenseDeclared/licenseConcluded fields. Every vulnerability's analysis.state is "in_triage" — npmscan surfaced an OSV/GHSA match for the resolved version but hasn't manually assessed exploitability, so that is the honest VEX state, not "exploitable". A package with no findings gets no vulnerability entry at all, and the CycloneDX dependencies[] transitive graph / any SPDX package hierarchy is intentionally left out — this only has a flat inventory, not resolved edges between packages.
| Name | Type | Required | Description |
|---|---|---|---|
| packages | array | optional | Explicit {name, version?} list — 1-1000 items, capped to 100 when includeLicenses is on. Use this OR `content`, not both. |
| content | string | optional | Raw package.json / lockfile / CycloneDX JSON / SPDX JSON content — same formats batch_query_vulnerabilities accepts. Use this OR `packages`, not both. |
| format | string | optional | "cyclonedx" | "spdx". Default "cyclonedx". |
| includeDevDependencies | boolean | optional | Only applies when `content` is a manifest/lockfile format that distinguishes dev dependencies. |
| includeVulnerabilities | boolean | optional | Query OSV.dev and embed findings natively. Default true. |
| includeLicenses | boolean | optional | Resolve registry license data and embed it natively. Default true. |
| policy | object | optional | { allow?: string[], deny?: string[] } — same shape as check_license_compliance. Only affects the echoed policy/licenseViolationCount, never blocks generation. |
| componentName | string | optional | Name of the SBOM's own root component/document, if known — sets SPDX documentDescribes to the matching package. |
| componentVersion | string | optional | Paired with componentName. |
minimist@1.2.5 is the same trusted prototype-pollution fixture used across npmscan's own tests (fixed in 1.2.6) — analysis.state is always "in_triage", never a stronger claim like "exploitable".
CVE-2021-44906 on the NIST NVDgenerate_sbom({
"packages": [{ "name": "minimist", "version": "1.2.5" }]
}){
"format": "cyclonedx",
"sbom": {
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"components": [
{ "bom-ref": "pkg:npm/minimist@1.2.5", "type": "library", "name": "minimist", "version": "1.2.5", "purl": "pkg:npm/minimist@1.2.5", "licenses": [{ "expression": "MIT" }] }
],
"vulnerabilities": [
{
"id": "GHSA-xvch-5gv4-984h",
"source": { "name": "GitHub Advisories", "url": "https://npmscan.com/vulnerability/GHSA-xvch-5gv4-984h" },
"references": [{ "id": "CVE-2021-44906", "source": { "name": "NVD" } }],
"ratings": [{ "source": { "name": "OSV" }, "severity": "critical" }],
"recommendation": "Upgrade to 1.2.6 or later.",
"affects": [{ "ref": "pkg:npm/minimist@1.2.5" }],
"analysis": { "state": "in_triage" }
}
]
},
"totalVulnerabilities": 1,
"packagesWithVulnerabilities": 1
}left-pad@1.3.0 has zero OSV findings, so no SECURITY/advisory externalRef is added at all — SBOM generators (this one included) only ever assert what was found, never assert absence.
generate_sbom({
"packages": [{ "name": "left-pad", "version": "1.3.0" }],
"format": "spdx"
}){
"format": "spdx",
"sbom": {
"spdxVersion": "SPDX-2.3",
"packages": [
{
"SPDXID": "SPDXRef-Package-0",
"name": "left-pad",
"versionInfo": "1.3.0",
"downloadLocation": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
"licenseDeclared": "WTFPL",
"licenseConcluded": "WTFPL",
"externalRefs": [
{ "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": "pkg:npm/left-pad@1.3.0" }
]
}
]
},
"totalVulnerabilities": 0,
"packagesWithVulnerabilities": 0
}Same minimist@1.2.5 as the first example, but with the OSV pass opted out of — useful when a caller only wants a license-focused SBOM and wants to skip the OSV round-trip for latency.
generate_sbom({
"packages": [{ "name": "minimist", "version": "1.2.5" }],
"includeVulnerabilities": false
}){
"format": "cyclonedx",
"sbom": {
"components": [
{ "bom-ref": "pkg:npm/minimist@1.2.5", "type": "library", "name": "minimist", "version": "1.2.5", "purl": "pkg:npm/minimist@1.2.5", "licenses": [{ "expression": "MIT" }] }
]
},
"totalVulnerabilities": 0,
"packagesWithVulnerabilities": 0
}- ›Shares its business logic with POST /api/analysis/generate-sbom.
- ›Reuses batch_query_vulnerabilities' own parsing and OSV enrichment, and check_license_compliance's own registry/policy resolution wholesale — the package cap tightens from 1000 to 100 whenever includeLicenses is on, the same per-package registry-fetch cost check_license_compliance itself caps at 100 for.
- ›`sbom` is validated against the official CycloneDX 1.6 / SPDX 2.3 JSON Schemas in npmscan's own test suite, not just assumed correct from hand-written field mapping.
npm audit's JSON is the single most common artifact a developer already has in hand when asking "what do I fix first," but it's a fundamentally different shape from a manifest/lockfile/SBOM — already a findings report, keyed by package with advisory chains, not a package list. This parses it directly (no re-pasting package.json/lockfile content) and calls the same runPrioritizeRemediation logic prioritize_remediation itself uses. The one non-obvious step: npm audit JSON almost never carries a CVE id, only a GHSA advisory URL, and KEV/EPSS are both keyed by CVE — so skipping GHSA resolution would silently degrade nearly every finding to severity-only ranking. Each GHSA without an already-known CVE is resolved via OSV.dev's alias data first; ghsaResolvedToCveCount in the result reports how many gained a CVE this way. Also carries through npm-audit-only context prioritize_remediation has no field for: isDirect (direct vs. transitive) and fixAvailable/fixTarget (note fixTarget can name a different package than the vulnerable one, e.g. bumping a parent to pull in a patched transitive dependency).
| Name | Type | Required | Description |
|---|---|---|---|
| content | string | required | Raw `npm audit --json` stdout — either npm 7+'s {"vulnerabilities": {...}} format (auditReportVersion 2) or legacy npm 6's {"advisories": {...}}. |
GHSA-jfh8-c2jp-5v3q (log4j-core's advisory) carries CVE-2021-44228 (Log4Shell) as its OSV alias — a long-standing CISA KEV entry, used as a regression guard in npmscan's own tests for the same reason prioritize_remediation's tests use it directly.
CVE-2021-44228 on the NIST NVDenrich_npm_audit({
"content": "{\"auditReportVersion\":2,\"vulnerabilities\":{\"vulnerable-log4j-wrapper\":{\"name\":\"vulnerable-log4j-wrapper\",\"severity\":\"critical\",\"isDirect\":true,\"via\":[{\"source\":1,\"name\":\"vulnerable-log4j-wrapper\",\"url\":\"https://github.com/advisories/GHSA-jfh8-c2jp-5v3q\",\"title\":\"Remote code execution\",\"severity\":\"critical\"}],\"fixAvailable\":true}}}"
}){
"inputFormat": "npm-audit-v2",
"ghsaResolvedToCveCount": 1,
"summary": { "patchNow": 1, "patchSoon": 0, "scheduled": 0, "monitor": 0, "kevListedCount": 1 },
"ranked": [
{
"rank": 1,
"packageName": "vulnerable-log4j-wrapper",
"cveId": "CVE-2021-44228",
"advisoryId": "GHSA-jfh8-c2jp-5v3q",
"kev": { "dateAdded": "2021-12-10", "requiredAction": "Apply updates per vendor instructions." },
"tier": "patch-now",
"isDirect": true,
"fixAvailable": true,
"fixTarget": null
}
]
}Legacy `advisories[].cves` gives the CVE directly, and `findings[0].version` gives the exact installed version — the one thing v2's format never states at all.
enrich_npm_audit({
"content": "{\"advisories\":{\"1179\":{\"id\":1179,\"module_name\":\"minimist\",\"severity\":\"critical\",\"cves\":[\"CVE-2021-44906\"],\"url\":\"https://github.com/advisories/GHSA-xvch-5gv4-984h\",\"findings\":[{\"version\":\"1.2.5\",\"paths\":[\"minimist\"]}]}}}"
}){
"inputFormat": "npm-audit-legacy",
"ghsaResolvedToCveCount": 0,
"ranked": [
{ "rank": 1, "packageName": "minimist", "cveId": "CVE-2021-44906", "currentVersion": "1.2.5", "fixedVersion": null }
]
}Both use report shapes different enough from npm's that silently misparsing them would produce wrong findings rather than an obvious failure — rejected up front instead, with a pointer to batch_query_vulnerabilities for those.
enrich_npm_audit({ "content": "{\"type\":\"auditAdvisory\",\"data\":{...}}" }){ "error": "Could not detect a supported npm audit JSON format — expected npm 7+ {\"vulnerabilities\": {...}} (auditReportVersion 2) or legacy npm 6 {\"advisories\": {...}}. `yarn audit --json` and `pnpm audit --json` use different report shapes and are not supported here — use batch_query_vulnerabilities with the project's manifest/lockfile instead." }- ›Shares its business logic with POST /api/analysis/enrich-npm-audit, and composes runPrioritizeRemediation in-process rather than re-implementing KEV/EPSS/severity scoring.
- ›One finding per top-level `vulnerabilities` package key (npm v2) — a package whose `via` is only chain pointers to another package's own advisory contributes no separate finding (counted in `skippedCount`, not dropped silently); a package with more than one distinct advisory only has the first used for ranking, named in a warning.
- ›A GHSA with no CVE alias in OSV falls back to severity-only ranking, the same documented behavior prioritize_remediation uses for any finding with no `cveId`.