See what your dependencies run at install time — and what denying it would cost.
npm v12 stopped running dependency lifecycle scripts by default. preinstall,
install and postinstall no longer fire unless your project explicitly
approves the package — and neither do the implicit node-gyp rebuild steps npm
used to synthesise for any package shipping a binding.gyp.
This is a good change. Install scripts have been the reliable foothold in npm supply-chain attacks for years: they run automatically, with your full user privileges, with your shell environment attached — which on a developer laptop means your npm token, your SSH keys and your cloud credentials.
It also left every Node project with a job to do, and npm's own tooling stops
one step short of helping with it. npm approve-scripts lists the packages that
want permission and asks you, one at a time, yes or no. It does not tell you:
- what the script actually does,
- whether denying it breaks your build or changes nothing at all,
- which of them are
node-gypbuilds you obviously need, and which are a funding message you obviously do not, - or which one is reading
~/.npmrc.
Faced with 60 packages and no information, approving all of them is the only move that reliably keeps the build working. That is a rubber stamp, and a rubber-stamped allowlist is worse than no allowlist, because it looks like review happened.
scriptgate is the missing step. It reads every install script in your tree, works out what each one is for, and tells you what denying it would cost.
$ scriptgate
SEVERITY PACKAGE CATEGORY RISK STATUS
critical shady-collector@0.9.1 unknown ██████████ 100 unreviewed
medium binary-fetcher@2.1.0 binary-download ███▍ 34 unreviewed
low telemetry-beacon@1.0.0 telemetry █▌ 15 unreviewed
info fast-native@3.0.0 native-build 0 approved
info gyp-implicit@1.4.2 native-build 0 stale pin
info hook-installer@9.0.0 dev-tooling 0 unreviewed (dev)
info thanks-printer@4.2.0 notice 0 denied
info tiny-noop@1.0.0 no-op 0 unreviewed
Summary
critical 1 · medium 1 · low 1 · info 5
5 unreviewed · 1 stale pin · 1 orphaned entryEvery row answers the question you actually have. native-build means denying
it leaves you with an addon that throws on require. notice means denying it
skips a console.log. unknown means nobody should approve it without reading
it first — so scriptgate refuses to put it in a generated allowlist.
npm install --save-dev scriptgateOr run it once, without installing:
npx scriptgateRequires Node 20.10 or newer. Zero runtime dependencies — a tool about the cost of trusting dependencies should not ask you to trust thirty of them.
# What runs at install time, and what denying it costs
scriptgate
# Read one package's script and the reasoning behind its rating
scriptgate explain sharp
# Record the recommended decisions in package.json
scriptgate allowlist --write
# Fail the build when an unreviewed script appears
scriptgate checkTry it against the bundled fixture, which contains one package of every shape:
git clone https://github.com/hamodywe/scriptgate && cd scriptgate
npm install
node src/cli.ts scan examples/demo-projectAnalyses the installed tree and reports. Reads node_modules, not the lockfile,
because that is what the next install will actually execute. Nothing is run —
scripts are read as text.
scriptgate # current directory
scriptgate scan ./packages/api # somewhere else
scriptgate --verbose # a detail block for every package
scriptgate --json # machine-readableThe screen where you decide. Prints the script in full, the files it runs, every signal found with its line number, and how those signals produced the rating.
$ scriptgate explain shady-collector
shady-collector@0.9.1 critical risk 100/100 · unknown
node_modules/shady-collector
Verdict
Reads files that hold credentials, which no install step legitimately needs.
It also reads the environment and contacts the network in the same script.
if denied: unknown — read the script before deciding
recommendation: review
Source — scripts/postinstall.js
14 const loot = {
15 env: process.env,
16 npmrc: fs.readFileSync(path.join(os.homedir(), '.npmrc'), 'utf8'),
17 };
Signals
credential-path (weight 35) — references a file that normally holds credentials
scripts/postinstall.js:16
.npmrcProduces the allowlist your package manager understands:
| Manager | Field | Shape |
|---|---|---|
| npm v12 | allowScripts |
{ "pkg@1.2.3": true } |
| pnpm | pnpm.onlyBuiltDependencies |
[ "pkg" ] |
| Yarn Berry | dependenciesMeta.<pkg>.built |
{ "built": true } |
scriptgate allowlist # print the block
scriptgate allowlist --write # merge it into package.jsonPackages the analysis could not attribute a purpose to are left out, and the
count is reported. --include-review overrides that, which means approving code
nobody has read.
The CI gate. Fails when a dependency runs code at install time that nobody has approved, or when an approval is pinned to a version that is no longer installed.
That second case is the quiet one. npm pins approvals as pkg@1.2.3. Bump the
dependency and the pin stops matching, so the script silently does not run — the
manifest still looks like it grants permission, the install still succeeds, and
the build breaks later somewhere that looks unrelated.
- run: npm ci
- run: npx scriptgate checkOr as code-scanning alerts, so findings appear on the pull request that introduced them and can be dismissed with a stated reason:
permissions:
security-events: write
steps:
- run: npm ci
- run: npx scriptgate scan --sarif > scriptgate.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: scriptgate.sarifFor a pull request comment, --markdown produces a table that leads with what
changed and collapses what was already settled. Full recipes in
docs/ci.md.
node_modules/ ──▶ discover ──▶ resolve ──▶ signals ──▶ classify ──▶ assess
│ │ │ │ │
every package the files what the what it is risk, severity,
with an install the command script does for, and and what denying
hook, plus the actually runs what denying it costs
implicit gyp it costs
│
package.json ──────────────────────────────────────────────────────▶ audit ─┘
allowlist approved · denied ·
unreviewed · stale
Five things make this more useful than grepping for postinstall:
It reads the file, not just the command. "postinstall": "node scripts/install.js"
tells you nothing. The interesting code is one hop away, and that is exactly
where most tools stop. scriptgate follows the command to the file and analyses
the source.
It catches the implicit node-gyp rebuild. A package that ships a
binding.gyp and declares no install script still executes a build, because npm
supplies one. There is nothing in package.json to grep for, and npm v12 blocks
it exactly like a written-out script.
It weighs signals in context. Reaching the network is unremarkable in
prebuild-install, whose whole job is downloading a prebuilt binary, and it is
a strong signal in a script whose purpose is unclear. Both are reported; only
the contribution to the score differs.
It scores combinations above their parts. Reading the environment is
ordinary. Contacting the network is ordinary. Doing both, automatically, on
npm install, with your shell environment attached, is the shape of every
credential-exfiltration incident the registry has had.
It is deterministic and offline. No network, no API keys, no telemetry, no service. Two scans of the same tree produce byte-identical reports, which is what makes a committed report diffable.
| Category | Deny it and… |
|---|---|
native-build |
the addon is never built; require throws at runtime |
binary-download |
the downloaded binary is missing; commands that call it fail |
codegen |
generated files are absent; the package may fail to import |
dev-tooling |
git hooks are not installed — nothing else, and CI is unaffected |
telemetry |
no install analytics are sent; the package works normally |
notice |
a console message is not printed |
no-op |
nothing at all |
unknown |
unknown — read it before deciding |
What matches each category, and why the rules are ordered the way they are: docs/categories.md. Weights, discounts and the combination bonuses: docs/scoring.md.
Optional. Every default is correct for a project that has never heard of this
file. Create scriptgate.config.json in the project root:
{
"failOn": "high",
"failOnUnreviewed": true,
"failOnOrphans": false,
"followScripts": true,
"//": "reviewed out of band on 2026-08-01, ticket SEC-441",
"ignore": ["legacy-native@2.3.1"]
}Keys beginning with // are ignored, which is how a reason gets written down
beside a decision. Ignoring is not approving — an ignored package is still
denied by the package manager unless it also appears in the allowlist.
Full reference: docs/configuration.md.
Stated plainly, because a security tool that oversells itself is worse than none.
- It is lexical analysis, not execution. scriptgate reports what a script
says it does. A script that assembles a URL from string fragments at runtime,
or fetches its real payload as a second stage, will not show a
networksignal. It cannot prove a package is safe. It can only tell you which ones are worth your attention first. - A clean report is not a clean bill of health. Zero findings means nothing matched, not that nothing is there.
- It reads the tree, so the tree must exist. Run
npm cifirst. There is no lockfile-only mode, because a lockfile records what should be installed rather than what is. - Dev/production reachability is exact only with an npm lockfile. npm computes it during resolution and records it. For pnpm and Yarn the flag is derived from the root manifest, which is right for direct dependencies and conservatively reports transitive ones as production. The report says which applied.
- Classification is heuristic. A package doing something genuinely unusual
lands in
unknown, which is the honest answer and deliberately the one that refuses to auto-approve. - Only npm lockfiles are parsed.
pnpm-lock.yamlis YAML, and shipping a YAML parser to recover one boolean costs more than it returns. Discovery works for all managers regardless, because it reads directories.
How is this different from npm audit?
npm audit matches your tree against a database of known, published
vulnerabilities. scriptgate asks a different question — what executes on your
machine at install time — and answers it without a database, which is why it
works on the day a package is compromised rather than after it is reported.
How is this different from npm approve-scripts?
npm's command is the ballot. scriptgate is the briefing you get before voting.
Does it run the scripts?
No. Nothing from node_modules is ever executed. Scripts are read as text.
Does it phone home? No. There is no network access anywhere in the tool.
Why not just deny everything? Because native addons stop working and you get a runtime crash that looks nothing like its cause. Denying blind and approving blind are the same mistake in opposite directions.
Can I use it with pnpm, Yarn or Bun? Discovery works with all of them, since they all materialise real package directories. Allowlist emission supports npm, pnpm and Yarn Berry.
Why zero dependencies? Because of what the tool is about. Every dependency added here would be another package with an install script somebody has to approve.
Bug reports, false findings and new detectors are all welcome — a false finding report is the most useful issue you can open, since the whole value of the tool rests on its classifications being trustworthy.
See CONTRIBUTING.md. Development is npm install, then
npm test — no build step required to run the CLI from source.
See ROADMAP.md.
MIT © hamodywe
- npm v12 breaking changes — GitHub Changelog, June 2026
npm approve-scripts— npm documentation- Preparing for npm v12 — GitHub community discussion
- NPM Security Cheat Sheet — OWASP