GitHub Action: Scan Dependency Changes in Pull Requests
Every pull request that changes package.json or a lockfile gets an npmscan report as a PR comment, and a failing check when something risky is added. No API key, one file to add.
Overview
npmscan Dependency Check is a GitHub Action that runs on your pull requests. It compares the dependency file on the PR with the one on your base branch, sends both to npmscan, and reports any package that is vulnerable, adds an install script, or quietly changed where it is downloaded from. Reviewers see the problem on the PR before anything is merged or installed in production.
It is free, needs no account or API key, and runs on GitHub's own runners. It is listed on the GitHub Marketplace and its source is public.
What It Flags
Known malware
A package version with a malicious-package advisory (an OSV MAL- record, or a GitHub advisory for embedded malicious code), e.g. ua-parser-js 0.7.29. It always blocks in mode: block, whatever fail-on-severity says, because these advisories are often rated only HIGH.
vulnerabilities[].isMalware
Known vulnerabilities
Every added or upgraded package is checked against OSV.dev and GitHub Advisories. The comment shows the highest severity; the API response lists each advisory with its fixed version.
isVulnerable / highestSeverity
New install scripts
A package version that adds a preinstall, install, or postinstall script its previous version did not have, or a newly added package that has one. This is the usual shape of a hijacked-maintainer attack. A prepare script does not count: npm never runs it when the package is installed from the registry.
installScriptIntroduced / hasInstallScript
Changed source or integrity hash
A lockfile entry whose tarball URL or integrity hash changed while the version number stayed the same, e.g. a lockfile pointing "lodash@4.17.21" at a different server; an entry whose tarball is a different package or version than it declares (the comment names the real one); or a version bump that suddenly downloads from a different host. Ordinary upgrades are not flagged. Lockfiles only.
sourceIntegrityChanged / identityMismatch
Your own scripts and overrides
A PR that adds or changes your project's own root preinstall, install, postinstall, or prepare script, or adds or changes an override/resolution. Each change gets its own row in a separate table in the comment.
projectLifecycleChanges / overridesChanges
What You Get on the PR
This is the real result for a PR that adds lodash@4.17.15. The comment is edited in place on every new push, so the PR never fills up with old reports. The same report is written to the workflow's job summary, and each finding is annotated on its line in the Files changed tab.
🛡️ npmscan dependency check
❌ 1 finding blocks this PR.
package.json
1 added · 0 removed · 0 changed · 1 flagged
| Package | Version | Vulnerabilities | Install scripts | Source / integrity | |
|---|---|---|---|---|---|
| ❌ | lodash | new → 4.17.15 | HIGH · 6 advisories | – | – |
▸ Advisories
mode: block · fail-on-severity: low · fail-on-install-script: true · fail-on-source-change: true · Scanned by npmscan.com · Docs
Findings marked ❌ meet your thresholds and fail the check; ⚠️ findings are reported but don't block. Changes to your own root scripts and to overrides/resolutions get their own tables, and the advisories behind each vulnerable package are listed in a collapsed section with their fixed versions.
Setup, Step by Step
New to GitHub Actions? An action is a small program GitHub runs for you when something happens in your repository. You turn it on by adding a workflow file. These steps take about five minutes, all in the browser.
- 1
Open your repository on GitHub
You need a repository with a package.json and permission to push to it. Nothing needs to be installed on your computer, and you do not need an npmscan account or API key.
- 2
Create the workflow file
On the repository page, click Add file → Create new file. In the name box, type .github/workflows/npmscan.yml (typing the slashes creates the folders). Paste the content below, then click Commit changes… → Commit changes.
.github/workflows/npmscan.ymlname: npmscan on: pull_request: paths: [package.json, package-lock.json, yarn.lock, pnpm-lock.yaml] permissions: contents: read pull-requests: write jobs: scan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: npmscan/npmscan-action@v1 - 3
Point it at the right file
By default the action scans package-lock.json, which covers every installed package including transitive ones. If your project has no package-lock.json, add a "with:" block under the npmscan step naming the file you do have: pnpm-lock.yaml, yarn.lock, or package.json.
Only if you do not use package-lock.json- uses: npmscan/npmscan-action@v1 with: file: pnpm-lock.yaml # or yarn.lock, or package.json - 4
Open a test pull request
Open package.json, click the pencil icon, and add a dependency with known vulnerabilities, such as "lodash": "4.17.15". At the bottom, choose "Create a new branch for this commit and start a pull request", then click Propose changes → Create pull request.
- 5
Read the result
Within about 30 seconds the PR shows a comment titled "npmscan dependency check" with a table of flagged packages, and the "npmscan / scan" check turns red. In the Files changed tab the flagged line is annotated. Click Details on the check to see the full log and the same report in the job summary. Close the test PR without merging.
- 6
Optional: block merging when it fails
Go to Settings → Rules → Rulesets → New ruleset → New branch ruleset. Set Enforcement status to Active, add your default branch as the target, tick "Require status checks to pass", add the "scan" check, and click Create. On private repositories this needs a paid GitHub plan.
Configuration
Set inputs in a with: block under the npmscan step. All are optional. By default everything flagged blocks the PR; use mode and the fail-on-* inputs to decide what fails the check. Findings below the thresholds are still reported.
| Input | Default | Description |
|---|---|---|
| file | package-lock.json | Path, relative to the repository root, of the file to compare: package.json, package-lock.json, npm-shrinkwrap.json, yarn.lock (classic or Berry), or pnpm-lock.yaml. |
| files | Several files to compare, one per line or comma-separated, e.g. every lockfile in a monorepo. Overrides file. All of them are reported in one PR comment. | |
| mode | block | block fails the check when a finding meets the fail-on-* thresholds below. warn reports the same findings, marks the ones that would have blocked, and always passes. |
| fail-on-severity | low | Lowest vulnerability severity that blocks: low, moderate, high, critical, or none to never block on vulnerabilities. Lower-severity findings are still reported. Known malware blocks regardless of this setting. |
| fail-on-install-script | 'true' | Block when a dependency gains a preinstall, install, or postinstall script, a newly added dependency has one, or your own root preinstall/install/postinstall/prepare script is added or changed. |
| fail-on-source-change | 'true' | Block when a lockfile entry changes its tarball URL or integrity hash, its tarball is a different package or version than declared, or an override/resolution is added or changed. |
| fail-on-error | 'false' | Fail the check when the scan itself cannot run (npmscan.com unreachable, rate limit, file over 8 MB). By default this is only a warning, so an outage never blocks your merges. |
| github-token | ${{ github.token }} | Token used to post the PR comment. The default works; it needs the pull-requests: write permission. |
| fail-on-flagged | 'true' | Deprecated, use mode. Setting it to 'false' is the same as mode: warn. |
Outputs
| Output | Description |
|---|---|
| flagged-count | Number of findings across all scanned files: flagged packages plus added or changed root scripts and overrides. Empty when nothing was scanned because every file was missing or unchanged. |
| blocking-count | Number of findings that meet the fail-on-* thresholds. They fail the check in mode: block; in mode: warn this is what would have blocked. |
| error-count | Number of files that could not be scanned (API unreachable, file too large). |
Common setups
Try it without blocking anything
Posts the full report but never fails the check. The comment marks which findings would have blocked, so you can switch to mode: block once you trust it.
- uses: npmscan/npmscan-action@v1
with:
mode: warnOnly block serious vulnerabilities
LOW and MODERATE advisories are still listed in the comment, but only HIGH and CRITICAL ones fail the check. New install scripts and changed sources still block.
- uses: npmscan/npmscan-action@v1
with:
fail-on-severity: highpnpm or Yarn projects
Scan the lockfile your package manager writes, so transitive dependencies are covered too.
- uses: npmscan/npmscan-action@v1
with:
file: pnpm-lock.yaml # or yarn.lockProject in a subfolder
Use the path from the repository root, and update the paths filter so the workflow triggers on it.
on:
pull_request:
paths: [apps/web/package.json, apps/web/package-lock.json]
# ...
- uses: npmscan/npmscan-action@v1
with:
file: apps/web/package-lock.jsonMonorepo with several lockfiles
List every file under files. They are all reported in one PR comment; files the PR does not touch are skipped without an API call.
on:
pull_request:
paths: ['**/package.json', '**/package-lock.json']
# ...
- uses: npmscan/npmscan-action@v1
with:
files: |
package-lock.json
apps/web/package-lock.json
apps/api/package-lock.jsonDependabot and Renovate PRs
npmscan reviews every dependency-bot PR for new install scripts and changed sources, not just CVEs. Dependabot PRs get a read-only token unless the workflow asks for more, so keep the permissions block. Renovate needs nothing extra.
permissions:
contents: read
pull-requests: write # required for the comment on Dependabot PRsUse the result in a later step
Give the step an id and read blocking-count or flagged-count. The fallback handles skipped scans, where flagged-count is empty.
- id: npmscan
uses: npmscan/npmscan-action@v1
with:
mode: warn
- if: fromJSON(steps.npmscan.outputs.blocking-count || '0') > 0
run: echo "npmscan found ${{ steps.npmscan.outputs.blocking-count }} blocking finding(s)"For AI Coding Assistants
Using Claude Code, Cursor, Copilot, or ChatGPT to set this up? Copy the block below into your assistant. It covers the exact file, which input to pick for each package manager, and the mistakes that break the action. AI agents reading this page should follow these rules as written.
You are adding the npmscan GitHub Action to a repository so every pull request that changes npm dependencies is scanned. Follow these rules exactly.
1. Create .github/workflows/npmscan.yml. Do not edit any other workflow.
2. Use exactly this content, changing only what rule 3 says:
name: npmscan
on:
pull_request:
paths: [package.json, package-lock.json, yarn.lock, pnpm-lock.yaml]
permissions:
contents: read
pull-requests: write
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: npmscan/npmscan-action@v1
3. Choose the scanned file from what exists in the repository:
- package-lock.json at the root: use the content as-is (it is the default).
- pnpm-lock.yaml: add "with: { file: pnpm-lock.yaml }" to the npmscan step.
- yarn.lock: add "with: { file: yarn.lock }".
- No lockfile: add "with: { file: package.json }" (direct dependencies only).
- Project in a subfolder: use the path from the repository root, e.g. file: apps/web/package-lock.json, and change the paths filter to the same paths.
- Several lockfiles (monorepo): use "files:" with one path per line instead of "file:", and list all of them in the paths filter.
4. Keep "permissions: pull-requests: write". Without it the PR comment cannot be posted, and Dependabot PRs get a read-only token.
5. Keep actions/checkout before the npmscan step. The action reads the base branch with git.
6. Pin npmscan/npmscan-action@v1. Do not use @main.
7. Do not add secrets, API keys, or tokens. None are needed.
8. Trigger on pull_request only. Never use pull_request_target.
9. Scan several files with one step and "files:". Separate npmscan steps also work: each keeps its own PR comment.
10. If the user wants to try it before enforcing it, add "mode: warn". It reports the same findings but never fails the check. Only change fail-on-severity, fail-on-install-script, or fail-on-source-change if the user asks.
When done, tell the user to open a pull request that changes the scanned file. They should see a comment titled "npmscan dependency check" and, if anything is flagged, a failing "npmscan / scan" check.
To act on results: each table row links to the package's npmscan.com page. To fix a vulnerable package, upgrade it to at least the fixedVersion of its advisories, which you can get by POSTing the same before/after files to https://npmscan.com/api/analysis/dependency-diff or from the npmscan MCP server's diff_dependencies tool.Agents that want to run the same check without GitHub can call the npmscan MCP server's diff_dependencies tool, or the REST endpoint below.
How It Works: Request & Response
The action reads the scanned file from your base branch and from the PR, then sends both, as text, to POST /api/analysis/dependency-diff. You can call it yourself from any CI system. This is the real request and response for the PR above:
curl -X POST https://npmscan.com/api/analysis/dependency-diff \
-H 'Content-Type: application/json' \
-d '{
"before": "{\"name\":\"my-app\",\"version\":\"1.0.0\",\"dependencies\":{\"express\":\"4.21.2\"}}",
"after": "{\"name\":\"my-app\",\"version\":\"1.0.0\",\"dependencies\":{\"express\":\"4.21.2\",\"lodash\":\"4.17.15\"}}"
}'{
"summary": "Compared package.json snapshots: 1 added, 0 removed, 0 changed — 1 flagged for a newly introduced install script, source/integrity change, override change, and/or vulnerability.",
"beforeFormat": "package.json",
"afterFormat": "package.json",
"comparisonNote": null,
"added": [
{
"name": "lodash",
"actualName": null,
"npmscanUrl": "https://npmscan.com/package/lodash",
"beforeVersion": null,
"afterVersion": "4.17.15",
"coexistingVersions": null,
"changeType": null,
"hasInstallScript": false,
"installScriptIntroduced": null,
"installScriptKeys": [],
"installScriptKeysIntroduced": null,
"sourceIntegrityChanged": null,
"resolvedUrl": null,
"integrity": null,
"isVulnerable": true,
"highestSeverity": "HIGH",
"vulnerabilities": [
{
"id": "GHSA-29mw-wpgm-hmr9",
"summary": "Regular Expression Denial of Service (ReDoS) in lodash",
"severity": "MODERATE",
"aliases": [
"CVE-2020-28500"
],
"publishedAt": "2022-01-06T20:30:46Z",
"fixedVersion": "4.17.21",
"npmscanUrl": "https://npmscan.com/vulnerability/GHSA-29mw-wpgm-hmr9"
},
{
"id": "GHSA-35jh-r3h4-6jhm",
"summary": "Command Injection in lodash",
"severity": "HIGH",
"aliases": [
"CVE-2021-23337",
"CVE-2026-4800"
],
"publishedAt": "2021-05-06T16:05:51Z",
"fixedVersion": "4.17.21",
"npmscanUrl": "https://npmscan.com/vulnerability/GHSA-35jh-r3h4-6jhm"
},
{
"id": "GHSA-f23m-r3pf-42rh",
"summary": "lodash vulnerable to Prototype Pollution via array path bypass in `_.unset` and `_.omit`",
"severity": "MODERATE",
"aliases": [
"CVE-2025-13465",
"CVE-2026-2950"
],
"publishedAt": "2026-04-01T23:50:27Z",
"fixedVersion": "4.18.0",
"npmscanUrl": "https://npmscan.com/vulnerability/GHSA-f23m-r3pf-42rh"
},
{
"id": "GHSA-p6mc-m468-83gw",
"summary": "Prototype Pollution in lodash",
"severity": "HIGH",
"aliases": [
"CVE-2020-8203"
],
"publishedAt": "2020-07-15T19:15:48Z",
"fixedVersion": "4.17.19",
"npmscanUrl": "https://npmscan.com/vulnerability/GHSA-p6mc-m468-83gw"
},
{
"id": "GHSA-r5fr-rjxr-66jc",
"summary": "lodash vulnerable to Code Injection via `_.template` imports key names",
"severity": "HIGH",
"aliases": [
"CVE-2021-23337",
"CVE-2026-4800"
],
"publishedAt": "2026-04-01T23:51:12Z",
"fixedVersion": "4.18.0",
"npmscanUrl": "https://npmscan.com/vulnerability/GHSA-r5fr-rjxr-66jc"
},
{
"id": "GHSA-xxjr-mmjv-4gpg",
"summary": "Lodash has Prototype Pollution Vulnerability in `_.unset` and `_.omit` functions",
"severity": "MODERATE",
"aliases": [
"CVE-2025-13465",
"CVE-2026-2950"
],
"publishedAt": "2026-01-21T23:01:22Z",
"fixedVersion": "4.17.23",
"npmscanUrl": "https://npmscan.com/vulnerability/GHSA-xxjr-mmjv-4gpg"
}
],
"vulnerabilityDelta": null,
"resolutionNote": null
}
],
"removed": [],
"changed": [],
"totalAdded": 1,
"totalRemoved": 0,
"totalChanged": 0,
"flaggedCount": 1,
"truncated": false,
"truncationNote": null,
"enrichmentNote": null,
"projectLifecycleChanges": null,
"overridesChanges": null
}Response fields
| Field | Type | Meaning |
|---|---|---|
| summary | string | One plain-English sentence describing the change. |
| flaggedCount | number | Total flagged items. The action breaks these down into individual findings and applies its fail-on-* thresholds to each one. |
| totalAdded / totalRemoved / totalChanged | number | How many packages were added, removed, or changed version. |
| added[] / changed[] | object[] | One entry per package, with the fields below. Packages appear in the comment table only if they are vulnerable or malware, add an install script (or are new and have one), or changed source. |
| .name / .npmscanUrl | string | Package name and its npmscan.com page. |
| .beforeVersion / .afterVersion | string | null | beforeVersion is null for a newly added package. |
| .isVulnerable / .highestSeverity | boolean / string | Whether the new version has known advisories, and the worst severity (LOW, MODERATE, HIGH, CRITICAL). |
| .vulnerabilities[] | object[] | Each advisory: id (GHSA, or MAL- for a malicious-package record), summary, severity, CVE aliases, fixedVersion, isMalware, and its npmscan.com page. The action always blocks when isMalware is true. |
| .installScriptIntroduced | boolean | null | true when this version adds a preinstall, install, or postinstall script the previous version lacked (prepare is excluded: it never runs on a registry install). installScriptKeysIntroduced names every new key, prepare included. |
| .hasInstallScript | boolean | Whether this version has a preinstall, install, or postinstall script. For a newly added package (beforeVersion null) the action flags it when this is true. |
| .sourceIntegrityChanged | boolean | null | true when a lockfile entry kept its version but changed its tarball URL or integrity hash, or a version bump moved to a different host. A routine bump is false. |
| .identityMismatch / .tarballName / .tarballVersion | boolean | null / string | null | identityMismatch is true when the lockfile tarball is a different package or version than the entry declares; tarballName/tarballVersion name the real one, and the vulnerability fields describe it. The action treats this as a source change. |
| .changeType | string | null | upgrade, downgrade, source-swap (same version, different tarball or hash), or unresolved. |
| removed[] | object[] | Packages removed by the PR (name, version, npmscanUrl). Never flagged. |
| projectLifecycleChanges | object | null | Changes to your own root preinstall/install/postinstall/prepare scripts in package.json, as introduced/changed/removed. null for lockfile scans. |
| overridesChanges | object | null | Changes to overrides, resolutions, or pnpm.overrides, as introduced/changed/removed. null for lockfile scans. |
| truncated / truncationNote | boolean / string | true for very large diffs: at most 500 entries per list are reported and 100 packages are checked in depth. |
Every endpoint is documented on the REST API page.
Troubleshooting
- Unable to resolve action npmscan/npmscan-action@v1
- Check the spelling in uses:. It must be npmscan/npmscan-action@v1.
- The workflow did not run on my PR
- The paths filter only triggers when the PR changes one of the listed files. If your project lives in a subfolder, list those paths instead.
- Notice: package-lock.json not found in this PR — nothing to scan
- Your repository uses a different file. Set the file input to pnpm-lock.yaml, yarn.lock, or package.json.
- Warning: Could not post the PR comment
- Add pull-requests: write under permissions. PRs from forks always get a read-only token, so they get no comment; the report is still in the job summary (click Details on the check) and findings still fail the check.
- No comment on Dependabot PRs
- GitHub gives Dependabot PRs a read-only token unless the workflow sets permissions. Add "permissions: { contents: read, pull-requests: write }" to the workflow, as in the setup example.
- Error: Run actions/checkout before npmscan/npmscan-action
- Add "- uses: actions/checkout@v7" as the step before the npmscan step.
- Error: npmscan-action only runs on pull_request events
- The action compares a PR with its base branch, so it needs on: pull_request. It does nothing useful on push.
- Warning: npmscan scan failed: … npmscan API returned HTTP …
- The message includes npmscan's reply. A 429 means the rate limit (30 requests per minute per IP) was hit; click Re-run jobs. A 400 usually means the file is not valid JSON/YAML or is over 8 MB. This is a warning by default and does not block the PR; set fail-on-error: true to make it fail the check.
- The comment shows findings but the check passed
- Either the workflow sets mode: warn, or the findings are below your thresholds (for example a MODERATE advisory with fail-on-severity: high). The line at the top of the comment says which, and the settings are listed at the bottom.
Limits & Privacy
- The action sends the before and after versions of the one scanned file to npmscan.com over HTTPS. The endpoint does not store them; they are only used to compute the diff.
- No API key or account. Requests share the public rate limit of 30 per minute per IP.
- package.json scans cover direct dependencies only; lockfile scans cover the full installed tree, including transitive dependencies.
- Up to 100 added or changed packages are checked in depth per run, and files can be up to 8 MB.
- PRs from forks get no comment because their token is read-only; the job summary and annotations still have the report.
- Each npmscan step keeps one comment per PR and edits it on every push. It only edits its own comment, never another bot's.
- If npmscan.com cannot be reached, the step warns and passes by default (fail-on-error: false), so an outage never blocks merging.
- The install-script check looks at whether a preinstall, install, or postinstall script exists, not at what it does (a dependency's prepare script never runs on install and is not counted). Use the npmscan MCP server or package page for a deeper look at anything flagged.