Skip to content

Repository files navigation

scriptgate

See what your dependencies run at install time — and what denying it would cost.

CI npm license zero dependencies


The problem

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-gyp builds 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.

What it looks like

$ 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 entry

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

Install

npm install --save-dev scriptgate

Or run it once, without installing:

npx scriptgate

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

Quick start

# 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 check

Try 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-project

The four commands

scriptgate scan (default)

Analyses 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-readable

scriptgate explain <package>

The 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
    .npmrc

scriptgate allowlist

Produces 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.json

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

scriptgate check

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.

In CI

- run: npm ci
- run: npx scriptgate check

Or 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.sarif

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

How it works

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.

Categories

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.

Configuration

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.

Limitations

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 network signal. 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 ci first. 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.yaml is 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.

FAQ

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.

Contributing

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.

Roadmap

See ROADMAP.md.

Licence

MIT © hamodywe

References

About

Triage npm install scripts for the npm v12 allowlist — reads what every preinstall, install and postinstall actually does, offline and deterministically.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages