Required Reading
~5 min

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.

First time connecting?
This page assumes your agent is already connected. For setup commands (Claude Code, Claude Desktop, ChatGPT, or any MCP-compatible client), see the MCP setup guide. Endpoint: 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.
Public & unauthenticated
No API key, no signup. Same read-only data that powers the website, exposed as structured tool calls instead of raw HTML.
Rate limited
Capped at 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.
How to read every example below
  • 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.

Input
NameTypeRequiredDescription
querystringrequiredSearch text, e.g. a package name or keywords (2–64 characters).
limitnumberoptionalMax results to return. Default 20, max 50.
Confirmed cleanA well-established package — no red flags

Real name, high download counts, no typosquat match. This is what a normal, trustworthy result looks like.

Call
search_packages({ "query": "react-router", "limit": 3 })
Result (truncated)success
{
  "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 */
  ]
}
Risk caughtTyposquat pattern caught in the 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.

Call
search_packages({ "query": "raect" })
Result (truncated)success
{
  "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"
    }
  ]
}
Edge caseQuery too short — rejected before hitting the registry

The 2-character minimum is enforced locally; a 1-character query never reaches npm.

Call
search_packages({ "query": "a" })
Errortool error
{ "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.

Input
NameTypeRequiredDescription
namestringrequiredExact npm package name, e.g. "lodash" or "@scope/name".
Confirmed cleanActively maintained, no signals worth flagging
Call
get_package({ "name": "axios" })
Result (truncated)success
{
  "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": []
}
Risk caughtMaintainer-declared deprecation surfaces immediately

`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 GitHub
Call
get_package({ "name": "request" })
Result (truncated)success
{
  "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": []
}
Edge caseUnknown package name
Call
get_package({ "name": "this-package-does-not-exist-anywhere" })
Errortool error
{ "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.

Input
NameTypeRequiredDescription
namestringrequiredExact npm package name.
versionstringrequiredExact version string, e.g. "4.17.21".
Risk caughtA known, long-fixed CRITICAL vulnerability

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 minimist
Call
get_package_version({ "name": "minimist", "version": "1.2.5" })
Resultsuccess
{
  "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"
    }
  ]
}
Confirmed cleanThe fixed version itself comes back clean

Same package, the very next release — confirms the tool isn't just flagging the package by name.

Call
get_package_version({ "name": "minimist", "version": "1.2.6" })
Resultsuccess
{
  "name": "minimist",
  "version": "1.2.6",
  "isVulnerable": false,
  "highestSeverity": null,
  "vulnerabilities": []
}
Edge caseVersion does not exist
Call
get_package_version({ "name": "minimist", "version": "999.0.0" })
Errortool error
{ "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.

Input
NameTypeRequiredDescription
maintainerUsernamestringrequiredExact npm username, e.g. "sindresorhus" — as shown at npmjs.com/~username.
Edge caseA prolific, entirely legitimate maintainer

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."

Call
get_maintainer_profile({ "maintainerUsername": "sindresorhus" })
Result (truncated)success
{
  "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
}
Edge caseUsername with no currently-published packages

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.

Call
get_maintainer_profile({ "maintainerUsername": "not-a-real-npm-user-xyz" })
Resultsuccess
{
  "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."

Input
NameTypeRequiredDescription
namestringrequirednpm package name.
versionstringoptionalOptional exact version to narrow results, e.g. one pinned in a lockfile.
ecosystemstringoptionalOSV ecosystem. Default "npm".
Risk caughtVersion-scoped: the exact vulnerable version
GHSA-xvch-5gv4-984h — Prototype Pollution in minimist
Call
query_vulnerabilities({ "name": "minimist", "version": "1.2.5" })
Resultsuccess
{
  "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" }
  ]
}
Confirmed cleanVersion-scoped: the fixed version
Call
query_vulnerabilities({ "name": "minimist", "version": "1.2.6" })
Resultsuccess
{
  "package": "minimist",
  "version": "1.2.6",
  "isVulnerable": false,
  "highestSeverity": null,
  "vulnerabilities": []
}
Edge caseNo version given — every historical CVE comes back, fixed ones included

Omitting version here would wrongly read as "minimist is currently unsafe" if you don't also check which version you actually have.

Call
query_vulnerabilities({ "name": "minimist" })
Result (truncated)success
{
  "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.

Input
NameTypeRequiredDescription
packagesarrayoptionalExplicit {name, version?} list (1-1000 items). Use this OR content, not both.
contentstringoptionalRaw package.json / package-lock.json / yarn.lock / pnpm-lock.yaml / CycloneDX JSON / SPDX JSON content. Use this OR packages, not both.
includeDevDependenciesbooleanoptionalOnly applies when content is a manifest/lockfile format that distinguishes dev dependencies.
Risk caughtOne known-vulnerable dependency caught inside a normal batch
Call
batch_query_vulnerabilities({
  "packages": [
    { "name": "minimist", "version": "1.2.5" },
    { "name": "is-number", "version": "7.0.0" }
  ]
})
Resultsuccess
{
  "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
}
Confirmed cleanWhole batch comes back clean
Call
batch_query_vulnerabilities({
  "packages": [
    { "name": "is-number", "version": "7.0.0" },
    { "name": "lodash", "version": "4.17.21" }
  ]
})
Resultsuccess
{
  "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
}
Edge casepackages and content given together — rejected

The two input modes are mutually exclusive; this fails before any OSV call is made.

Call
batch_query_vulnerabilities({
  "packages": [{ "name": "lodash" }],
  "content": "{ \"dependencies\": { \"lodash\": \"4.17.21\" } }"
})
Errortool error
{ "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.

Input
NameTypeRequiredDescription
typestringoptional"reviewed" | "malware" | "osv" (default reviewed).
severitystringoptional"critical" | "high" | "medium" | "low" | "all" (default all; not applicable to malware/osv).
categorystringoptionalVulnerability category id, e.g. "xss", "sql-injection", "ssrf", "access-control" (reviewed only).
affectsstringoptionalFilter to advisories affecting this npm package name.
ghsaIdstringoptionalExact lookup by GHSA ID (reviewed/malware only).
cveIdstringoptionalExact lookup by CVE ID (reviewed/malware only).
directionstringoptional"asc" | "desc" by published date (default desc).
cursorstringoptionalOpaque pagination cursor from a previous response's nextCursor.
Risk caughtCategory filter actually filters — every result carries the label

The npmscan test suite asserts this directly: every advisory returned for category="xss" must carry the XSS category label, not just be loosely related.

Call
get_latest_advisories({ "category": "xss", "severity": "high" })
Result (truncated)success
{
  "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 */
  ]
}
Confirmed cleanExact GHSA lookup with no matches to sort through
Call
get_latest_advisories({ "ghsaId": "GHSA-xvch-5gv4-984h" })
Resultsuccess
{
  "advisories": [
    { "id": "GHSA-xvch-5gv4-984h", "cve": "CVE-2021-44906", "summary": "Prototype Pollution in minimist", "severity": "critical" }
  ]
}
Edge casedirection sorts oldest-first vs. newest-first

A neutral, non-error case — both directions succeed, this is a sort-order sanity check, not a risk signal.

Call
get_latest_advisories({ "direction": "asc" })
Result (truncated)success
{ "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.

Input
NameTypeRequiredDescription
cveIdstringoptionalExact CVE ID for a single lookup, e.g. "CVE-2021-44228". Omit search filters when this is given.
keywordSearchstringoptionalFree-text search, e.g. a package or product name.
severitystringoptional"CRITICAL" | "HIGH" | "MEDIUM" | "LOW" — CVSS v3 base severity filter.
cweIdstringoptionalFilter by weakness type, e.g. "CWE-79".
publishedSince / publishedUntilstringoptionalYYYY-MM-DD date range, must be given together, capped at 120 days.
resultsPerPagenumberoptionalMax results for a search (default 10, max 50).
startIndexnumberoptionalPagination offset for a search.
Risk caughtA real, actively-exploited CVE with KEV + EPSS enrichment

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 NVD
Call
get_cve({ "cveId": "CVE-2021-44228" })
Result (truncated)success
{
  "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" }
}
Confirmed cleanUnknown CVE ID — a normal not-found, not a tool error
Call
get_cve({ "cveId": "CVE-2099-99999" })
Resultsuccess
{ "cveId": "CVE-2099-99999", "found": false, "npmscanUrl": "https://npmscan.com/vulnerability/CVE-2099-99999" }
Edge caseNo filters at all — rejected before it ever reaches NVD

Also rejected: malformed CVE IDs, and a publishedSince given without publishedUntil (or vice versa).

Call
get_cve({})
Errortool error
{ "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).

Input
NameTypeRequiredDescription
namestringrequiredExact npm package name.
versionstringoptionalExact version to analyze; omit to use the latest published version.
Risk caughtEvery red flag at once — a synthetic worst-case fixture

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.

Call
analyze_install_script({ "name": "example-malicious-pkg" })
Result (truncated)success
{
  "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"
}
Confirmed cleanA package with no lifecycle scripts scores clean

Real package, real call — lodash has no preinstall/install/postinstall/prepare entries at all, so there is nothing to scan.

Call
analyze_install_script({ "name": "lodash" })
Resultsuccess
{
  "name": "lodash",
  "version": "4.17.21",
  "hasLifecycleScripts": false,
  "lifecycleScripts": {},
  "findings": [],
  "totalScore": 0,
  "riskTier": "none"
}
Edge caseA real, legitimate postinstall still scores above baseline

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 download
Call
analyze_install_script({ "name": "cypress" })
Result (truncated)success
{
  "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.

Input
NameTypeRequiredDescription
packagesarrayrequired1-15 root packages to expand from, e.g. a package.json's "dependencies". version accepts an exact version or a semver range; omitted = latest.
maxDepthnumberoptionalLevels of transitive expansion beyond the roots (0 = roots only). Default 2, capped at 3.
Risk caughtA known-vulnerable package surfaces two levels deep

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 minimist
Call
analyze_transitive_dependencies({
  "packages": [{ "name": "optimist", "version": "0.6.1" }],
  "maxDepth": 2
})
Result (truncated)success
{
  "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
}
Confirmed cleanDiamond dependency merges into one node, graph is clean

express and body-parser both depend on debug — it appears once in the resolved graph, not twice, and nothing in it is vulnerable.

Call
analyze_transitive_dependencies({
  "packages": [{ "name": "express" }, { "name": "body-parser" }]
})
Result (truncated)success
{
  "summary": "Scanned 14 packages across 2 root(s); 0 vulnerable packages found.",
  "vulnerablePaths": [],
  "totalPackagesScanned": 14,
  "vulnerablePackageCount": 0,
  "truncated": false
}
Edge caseA git-URL dependency can't be resolved from the registry

Not a crash — surfaced per-node as a resolutionError so the rest of the graph still scans.

Call
analyze_transitive_dependencies({ "packages": [{ "name": "some-fork", "version": "git+https://github.com/user/some-fork.git" }] })
Result (truncated)success
{
  "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.

Input
NameTypeRequiredDescription
namestringrequiredExact npm package name.
versionstringoptionalExact version to check; omit to use the latest published version.
Confirmed cleanWell-formed provenance, zero findings
Call
check_package_provenance({ "name": "semver", "version": "7.6.3" })
Result (truncated)success
{
  "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"
}
Risk caughtMissing provenance while npm-scope siblings have it

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 yourself
Call
check_package_provenance({ "name": "@npmcli/arborist" })
Result (truncated)success
{
  "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"
}
Edge casePre-provenance package with pre-provenance peers — not an anomaly

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"
Call
check_package_provenance({ "name": "lodash" })
Result (truncated)success
{
  "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.

Input
NameTypeRequiredDescription
namestringrequiredExact npm package name.
Risk caughtA real, named incident: the Sept 2025 "qix" chalk/debug compromise

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 chalk
Call
check_maintainer_changes({ "name": "chalk" })
Result (truncated)success
{
  "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"
}
Confirmed cleanA real 2018 incident, fully resolved — decays to zero, not flagged forever

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 incident
Call
check_maintainer_changes({ "name": "event-stream" })
Result (truncated)success
{
  "name": "event-stream",
  "history": { "changes": [], "note": "No maintainer changes within the lookback window" },
  "findings": [],
  "totalScore": 0,
  "riskTier": "none"
}
Edge caseA real GitHub repository transfer, not a compromise

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 firsthand
Call
check_maintainer_changes({ "name": "jade" })
Result (truncated)success
{
  "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.

Input
NameTypeRequiredDescription
maintainerUsernamestringrequiredExact npm username, e.g. "sindresorhus" — as shown at npmjs.com/~username.
Risk caughtA real, currently-live compromise: the August 2026 keyv/cacheable "Shai-Hulud" worm

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 Attack
Call
check_maintainer_blast_radius({ "maintainerUsername": "jaredwray" })
Result (truncated, verified live)success
{
  "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"
}
Edge caseA real coordinated release on a huge, entirely legitimate account — a cluster still fires, correctly hedged

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 packages
Call
check_maintainer_blast_radius({ "maintainerUsername": "sindresorhus" })
Result (truncated)success
{
  "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.

Input
NameTypeRequiredDescription
rulesarrayoptional1-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.
idstringoptionalA playbook slug to look up directly, e.g. "postinstall-binary" — see /docs/playbooks. At least one of rules/id is required.
Risk caughtA full maintainer turnover finding resolves to its playbook

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.

Call
get_remediation_playbook({ "rules": ["full-maintainer-turnover"] })
Result (truncated)success
{
  "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"
    }
  ]
}
Risk caughtTwo different rules from the same findings array collapse to one playbook, each with its own situationNote

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.

Call
get_remediation_playbook({ "rules": ["obfuscation", "exfil-hosts"] })
Result (truncated)success
{
  "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" }
  ]
}
Risk caughtDirect lookup by playbook id
Call
get_remediation_playbook({ "id": "postinstall-binary" })
Result (truncated)success
{
  "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" }
      ]
    }
  ]
}
Edge caseA baseline-only finding has no dedicated playbook

"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.

Call
get_remediation_playbook({ "rules": ["lifecycle-present"] })
Resultsuccess
{
  "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.

Input
NameTypeRequiredDescription
packagesarrayrequired1-100 {name, version?} packages to check. version accepts an exact version or a semver range; omitted = latest.
policyobjectoptional{ allow?: string[], deny?: string[] } — SPDX ids, family prefixes (e.g. "GPL"), or category names. Omit for the default policy.
Risk caughtDefault policy sweep — real GPL violators next to real compliant packages

One call mixing genuinely copyleft-licensed packages with permissively-licensed ones under the default policy.

Call
check_license_compliance({
  "packages": [
    { "name": "graphviz" },
    { "name": "lightningcss" },
    { "name": "lodash" }
  ]
})
Result (truncated)success
{
  "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
}
Confirmed cleanAn SPDX OR expression is compliant even with a denied license on one side

policy.deny lists GPL-3.0, but the package is dual-licensed — the MIT side is legally sufficient, so it is not a violation.

Call
check_license_compliance({
  "packages": [{ "name": "expand-template" }],
  "policy": { "deny": ["GPL-3.0"] }
})
Resultsuccess
{
  "results": [
    { "package": { "name": "expand-template" }, "rawLicense": "(MIT OR WTFPL)", "category": "permissive", "isCompliant": true, "needsReview": false }
  ],
  "violationCount": 0
}
Edge caseUnrecognized license string — surfaced for review, not silently passed

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.

Call
check_license_compliance({
  "packages": [{ "name": "ckeditor4" }],
  "policy": { "allow": ["MIT"] }
})
Resultsuccess
{
  "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.

Input
NameTypeRequiredDescription
beforestringrequiredRaw "before" snapshot content — format auto-detected.
afterstringrequiredRaw "after" snapshot content — may be a different format than before.
Confirmed cleanA vulnerability fixed by the change
Call
diff_dependencies({
  "before": "{ \"dependencies\": { \"minimist\": \"1.2.5\" } }",
  "after": "{ \"dependencies\": { \"minimist\": \"1.2.6\" } }"
})
Resultsuccess
{
  "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
}
Risk caughtA vulnerability introduced by a deliberate downgrade

Same two versions, swapped — the tool doesn't just check the final version's status, it reports the direction of the change.

Call
diff_dependencies({
  "before": "{ \"dependencies\": { \"minimist\": \"1.2.6\" } }",
  "after": "{ \"dependencies\": { \"minimist\": \"1.2.5\" } }"
})
Resultsuccess
{
  "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
}
Edge caseCross-format reclassification: a range vs. a pinned resolved version

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.

Call
diff_dependencies({
  "before": "{ \"dependencies\": { \"lodash\": \"^4.17.21\" } }",
  "after": "{ \"packages\": { \"node_modules/lodash\": { \"version\": \"4.17.21\" } } }"
})
Resultsuccess
{
  "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.

Input
NameTypeRequiredDescription
findingsarrayrequired1-200 {packageName, cveId?, severity?, currentVersion?, fixedVersion?, advisoryId?} findings to rank.
Risk caughtA confirmed, actively-exploited CVE ranks patch-now regardless of what else is in the batch

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 NVD
Call
prioritize_remediation({
  "findings": [
    { "packageName": "vulnerable-log4j-wrapper", "cveId": "CVE-2021-44228", "severity": "CRITICAL" },
    { "packageName": "is-number", "severity": "LOW" }
  ]
})
Result (truncated)success
{
  "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" }
  ]
}
Confirmed cleanThe same CVE across two packages is deduplicated, not double-scored
Call
prioritize_remediation({
  "findings": [
    { "packageName": "pkg-a", "cveId": "CVE-2021-44906", "severity": "CRITICAL" },
    { "packageName": "pkg-b", "cveId": "CVE-2021-44906", "severity": "CRITICAL" }
  ]
})
Resultsuccess
{
  "totalFindings": 2,
  "uniqueCveCount": 1,
  "ranked": [
    { "rank": 1, "packageName": "pkg-a", "cveId": "CVE-2021-44906" },
    { "rank": 2, "packageName": "pkg-b", "cveId": "CVE-2021-44906" }
  ]
}
Edge caseMore than 200 findings in one call — rejected

Also rejected: an empty findings array, and a malformed cveId that doesn't match the CVE-YYYY-NNNN pattern.

Call
prioritize_remediation({ "findings": [ /* 201 items */ ] })
Errortool error
{ "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.

Input
NameTypeRequiredDescription
packageNamestringrequiredExact npm package name, e.g. "lodash" or "@scope/name".
currentVersionstringrequiredCurrently installed version — exact version, semver range, or dist-tag.
targetVersionstringoptionalVersion to simulate upgrading to — exact version, range, or dist-tag. Omit for the registry's "latest" dist-tag.
Confirmed cleanA major version bump — flagged as likely-breaking even with a clean OSV check

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.

Call
simulate_dependency_upgrade({
  "packageName": "lodash",
  "currentVersion": "3.10.1",
  "targetVersion": "4.17.21"
})
Result (truncated)success
{
  "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."]
}
Risk caughtA patch bump straight from a prioritize_remediation fixedVersion — a real fixed CVE

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 NVD
Call
simulate_dependency_upgrade({
  "packageName": "minimist",
  "currentVersion": "1.2.5",
  "targetVersion": "1.2.6"
})
Result (truncated)success
{
  "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."]
}
Edge caseAn unresolvable target version — a normal result, not a tool error

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.

Call
simulate_dependency_upgrade({
  "packageName": "lodash",
  "currentVersion": "4.17.21",
  "targetVersion": "^99.0.0"
})
Resultsuccess
{
  "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.

Input
NameTypeRequiredDescription
namestringrequiredExact npm package name, e.g. "request" or "node-sass".
reasonstringoptional"deprecated" | "vulnerable" | "abandoned" | "typosquat" | "general" — biases filtering/ranking.
limitnumberoptionalMax suggestions to return. Default 5, max 10.
Risk caughtMaintainer-provided replacement hints honored directly

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 yourself
Call
suggest_alternative({ "name": "node-sass" })
Result (truncated)success
{
  "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." }
  ]
}
Confirmed cleanProse-only deprecation notice — no hallucinated package name

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 yourself
Call
suggest_alternative({ "name": "request-promise" })
Result (truncated)success
{
  "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." }
  ]
}
Edge caseA built-in language feature, not a package

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 MDN
Call
suggest_alternative({ "name": "left-pad" })
Resultsuccess
{
  "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.

Input
NameTypeRequiredDescription
packagesstring[]required2-5 exact npm package names to compare, e.g. ["axios", "got", "node-fetch"].
Risk caughtThree actively-maintained HTTP clients — a clear, reasoned pick, plus a hidden install-size gap

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.

Call
compare_packages({ "packages": ["axios", "got", "node-fetch"] })
Result (truncated)success
{
  "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"
  }
}
Edge caseA deprecated candidate is shown, not silently dropped — and never picked

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.

Call
compare_packages({ "packages": ["axios", "request", "node-fetch"] })
Result (truncated)success
{
  "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"
  }
}
Edge caseAn unresolvable name still returns a usable comparison

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.

Call
compare_packages({ "packages": ["axios", "got", "definitely-not-a-real-package-xyz"] })
Result (truncated)success
{
  "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.

Input
NameTypeRequiredDescription
urlstringrequiredGitHub repository URL, e.g. "https://github.com/owner/repo".
refstringoptionalBranch, tag, or commit SHA to audit; omit to use the repository's default branch.
includeDevDependenciesbooleanoptionalInclude package.json devDependencies (root and, for a monorepo, every merged workspace member). Default false; ignored when a pnpm/yarn lockfile is used instead.
policyobjectoptional{ allow?: string[], deny?: string[] } — same shape as check_license_compliance. Omit for the default policy (only copyleft/network-copyleft/proprietary are violations).
Risk caughtA real repo with a flagged install script

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.

Call
audit_github_repository({ "url": "https://github.com/expressjs/express" })
Result (truncated)success
{
  "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
}
Edge caseA monorepo — every workspace member's dependencies are merged in, not just root's

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" field
Call
audit_github_repository({ "url": "https://github.com/npm/cli" })
Result (truncated)success
{
  "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
}
Edge caseNo package.json in the repo — a clear 404-style error, not a crash
Call
audit_github_repository({ "url": "https://github.com/github/gitignore" })
Errortool error
{ "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.

Input
NameTypeRequiredDescription
packagesarrayoptionalExplicit {name, version?} list — 1-1000 items, capped to 100 when includeLicenses is on. Use this OR `content`, not both.
contentstringoptionalRaw package.json / lockfile / CycloneDX JSON / SPDX JSON content — same formats batch_query_vulnerabilities accepts. Use this OR `packages`, not both.
formatstringoptional"cyclonedx" | "spdx". Default "cyclonedx".
includeDevDependenciesbooleanoptionalOnly applies when `content` is a manifest/lockfile format that distinguishes dev dependencies.
includeVulnerabilitiesbooleanoptionalQuery OSV.dev and embed findings natively. Default true.
includeLicensesbooleanoptionalResolve registry license data and embed it natively. Default true.
policyobjectoptional{ allow?: string[], deny?: string[] } — same shape as check_license_compliance. Only affects the echoed policy/licenseViolationCount, never blocks generation.
componentNamestringoptionalName of the SBOM's own root component/document, if known — sets SPDX documentDescribes to the matching package.
componentVersionstringoptionalPaired with componentName.
Risk caughtCycloneDX: a real advisory lands in the native top-level vulnerabilities[] array

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 NVD
Call
generate_sbom({
  "packages": [{ "name": "minimist", "version": "1.2.5" }]
})
Result (truncated)success
{
  "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
}
Confirmed cleanSPDX: a genuinely clean package carries only its purl externalRef — no fabricated "clean" record

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.

Call
generate_sbom({
  "packages": [{ "name": "left-pad", "version": "1.3.0" }],
  "format": "spdx"
})
Result (truncated)success
{
  "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
}
Edge caseincludeVulnerabilities:false skips the OSV pass entirely — no vulnerabilities[] key, not an empty one

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.

Call
generate_sbom({
  "packages": [{ "name": "minimist", "version": "1.2.5" }],
  "includeVulnerabilities": false
})
Resultsuccess
{
  "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).

Input
NameTypeRequiredDescription
contentstringrequiredRaw `npm audit --json` stdout — either npm 7+'s {"vulnerabilities": {...}} format (auditReportVersion 2) or legacy npm 6's {"advisories": {...}}.
Risk caughtA GHSA-only v2 finding resolves to a real, CISA-KEV-listed CVE and ranks patch-now

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 NVD
Call
enrich_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}}}"
})
Result (truncated)success
{
  "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
    }
  ]
}
Confirmed cleanLegacy npm 6 format already carries a real CVE — no OSV round-trip needed

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.

Call
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\"]}]}}}"
})
Resultsuccess
{
  "inputFormat": "npm-audit-legacy",
  "ghsaResolvedToCveCount": 0,
  "ranked": [
    { "rank": 1, "packageName": "minimist", "cveId": "CVE-2021-44906", "currentVersion": "1.2.5", "fixedVersion": null }
  ]
}
Edge case`yarn audit --json` / `pnpm audit --json` input — rejected, not misparsed

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.

Call
enrich_npm_audit({ "content": "{\"type\":\"auditAdvisory\",\"data\":{...}}" })
Errortool error
{ "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`.