Know what an install script actually does before you approve it.
The install-script-approval tool for npm Β· pnpm Β· yarn Β· bun, from your CLI, CI, editor, and AI agent.
Since npm v12 (July 8, 2026), dependency lifecycle scripts (preinstall, install, postinstall) and implicit node-gyp builds no longer run unless explicitly allowed via the allowScripts field in package.json. git and remote-URL dependencies no longer resolve at all unless opted in via allow-git/allow-remote. And npm isn't alone: pnpm (allowBuilds), yarn Berry (dependenciesMeta.built), and bun (trustedDependencies) all made install scripts opt-in too. That leaves every team, on every package manager, staring at a list of package names asking: which of these are safe to approve?
npm-script-lens answers that with evidence, not vibes: the review-report mode the community asked for in npm/rfcs#897. For every package in your lockfile (package-lock.json, npm-shrinkwrap.json, yarn.lock (classic and berry), pnpm-lock.yaml, or bun.lock) it:
- fetches the version metadata from the public npm registry,
- stream-downloads the tarball and indexes its source files (
tar-stream, nothing written to disk), skipped entirely for the majority of packages with no install-time scripts, which is why real audits take seconds, - statically analyzes each
preinstall/install/postinstallscript withacorn, including the JS the script actually runs:node <file>targets,node -eeval bodies, relativerequire()/importchains,path.join(__dirname, β¦)indirections, andnpm run <target>recursion into the package's own scripts (3 levels deep, cycle-safe). Packages that ship a rootbinding.gypwith no install script get their implicitnode-gyp rebuildsurfaced too, since npm v12 blocks those builds as well. (prepareis deliberately excluded: npm never runs it for registry-installed deps, and flagging leftover"prepare": "husky install"lines would be noise.) - reads inside
binding.gyp(and the.gypi/.gypfiles it includes) for native packages, see the gyp lens, because gyp runs the commands in that file at configure time, - scores the behavior and emits a Markdown report plus a ready-to-paste, version-pinned
allowScriptsblock, - adds context to every risky package: how it entered your tree (
via prisma β @prisma/engines), whether OSV lists it as malicious (β hard flag, always denied), and publisher trust signals (publish age, weekly downloads, maintainer count, and the resolved provenance identity: which repository, workflow, ref and commit the attestation claims built it), so "π΄ HIGH, 74M dl/wk, 10 years old" reads differently from "π΄ HIGH, published 4 days ago, 12 dl/wk".
| Risk | Meaning |
|---|---|
| π΄ HIGH | spawns processes (child_process, execa, node-gyp, unresolved binaries) or runs constructed code (eval, new Function, vm, string-built require(), base64/char-code payload decoding) |
| π MEDIUM | network access (http(s).get/request, fetch, axios/got/node-fetch/β¦) without exec |
| π‘ LOW | filesystem writes or process.env reads only |
| π’ SAFE | none of the above |
Results are cached on disk keyed name@version + tool version (published tarballs are immutable), so repeat audits are near-instant and fully offline. --no-cache opts out; NPM_SCRIPT_LENS_CACHE_DIR relocates the cache.
npx npm-script-lens audit --path ./my-project --fail-on-high
# --path PATH project dir or lockfile: package-lock.json, npm-shrinkwrap.json,
# yarn.lock, pnpm-lock.yaml, bun.lock (default: .). A directory
# with no lockfile searches upward like npm does; a directory of
# checkouts audits every project underneath
# --json machine-readable output ({results, allowScripts}, or {projects}
# when more than one project was found)
# --out FILE write report to a file
# --sarif FILE also write SARIF 2.1.0 for GitHub code scanning
# --html FILE also write a self-contained, shareable HTML report
# --diff BASE audit only packages added/upgraded vs a base lockfile
# --since REF like --diff, but extract the base lockfile from a git ref
# --offline analyze node_modules on disk instead of the registry
# --no-trust skip OSV/downloads/provenance enrichment
# --no-cache disable the on-disk result cache
# --fail-on-high exit 1 if any package scores HIGH or is known malicious
# --cooldown [h] exit 1 if any locked version is younger than N hours (default 72)
# --cooldown-allow PKG... exempt from --cooldown, by name or name@versionReviewing a PR? Audit only what changed, and see what upgrades gained:
npx npm-script-lens audit --since origin/main --fail-on-high # base lockfile pulled from the ref for you
# β¦or point --diff at a base lockfile you extracted yourself:
git show origin/main:package-lock.json > /tmp/base-lock.json
npx npm-script-lens audit --diff /tmp/base-lock.json --fail-on-highIn diff mode, a package that was already in the tree but changed version is compared against the base version's analysis: **β οΈ gained vs 1.2.0:** net: fetch() is the fingerprint of a hijacked release (event-stream, the 2025 Shai-Hulud wave); no new capabilities vs 1.2.0 is a boring upgrade.
npm-script-lens is the only install-script allowlist/approval tool that reads
inside binding.gyp and .gypi, and the only one that diffs them
between versions.
Every such tool (including this one, before v1.3.0) treated binding.gyp as a
flag: present β "implicit node-gyp rebuild". But gyp evaluates that file
before a line of C is compiled, and executes the commands in it,
subprocess.run(contents, stdout=PIPE, shell=use_shell, β¦)
in gyp-next. So the build file is a place to put install-time code where
approval tooling was not looking. That is what the June 2026 campaign used:
ReversingLabs, 2026-06-04
(286 malicious versions across 56 packages), whose payload was a single line:
{"targets":[{"target_name":"Setup","type":"none","sources":["<!(node index.js > /dev/null 2>&1 && echo stub.c)"]}]}Aikido's 2026-06-09 teardown
enumerates the channels; npm-script-lens covers all of them:
| Channel | What it does |
|---|---|
<!( <!@( |
command expansion: gyp runs it in a shell and substitutes the output |
>!( >!@( ^!( ^!@( |
the same thing in gyp's late and latelate phases, one character apart from <!(, and invisible to a naive scan |
<!pymod_do_main( (+ >/^) |
imports a Python module and calls its DoMain() |
<|( >|( ^|( |
listfile expansion |
actions[].action Β· rules[].action Β· postbuilds[].action |
explicit build steps that run commands |
make_global_settings |
replaces CC/CXX/LINK, a compiler hijack |
conditions |
flagged when the condition string reaches for the Python-eval sandbox escape (__class__, __subclasses__, __import__, __builtins__) |
Plain <(var) / <@(var) interpolation is never flagged, real files mix
both, and bufferutil's <!(cc -v β¦) sitting next to its <(clang_version)
is a committed regression test.
review prints what will run above the file itself. Real output for
better-sqlite3@11.10.0, note the findings come from deps/sqlite3.gyp, a
file the parent binding.gyp only references:
ββ better-sqlite3@11.10.0 [π΄ HIGH]
deps/sqlite3.gyp:28 actions[].action build action β node copy.js <(SHARED_INTERMEDIATE_DIR)/sqlite3
deps/sqlite3.gyp:41 actions[].action build action β node copy.js <(SHARED_INTERMEDIATE_DIR)/sqlite3 <(sqlite3)
ββ binding.gyp (39 lines)
β 1 # ===
β 2 # This is the main GYP file, which builds better-sqlite3 with SQLite itself.
β¦
In audit, these become gyp: signals that score HIGH (a shell command at
install time is a shell command), appear in --sarif under the rule
gyp-exec-channel, and can be banned outright via a policy's
denyCapabilities: ["gyp"].
Upgrading from β€ 1.2.0? A
manifest --checkbaseline containing native packages may now show a newgypcapability, the tool sees something it previously could not. Re-baseline once withmanifest --writeand commit it.
Before you bump a pin, see exactly which install-time behavior a new version adds or changes, the surface npm v12 will ask you to re-approve. diff compares the preinstall/install/postinstall scripts (and the implicit node-gyp rebuild that ships with a root binding.gyp) between two versions, straight from the registry:
npx npm-script-lens diff sharp@0.32.6 sharp@0.33.0
# --json emit { unchanged, added, removed, modified, gyp } instead of colored textsharp@0.32.6 β sharp@0.33.0
REMOVED: implicit node-gyp rebuild (binding.gyp)
MODIFIED: install
- (node install/libvips && node install/dll-copy && prebuild-install) || β¦
+ node install/check
- UNCHANGED (green): key present in both, byte-identical
- ADDED (red): a new script, or a gained
binding.gypβADDED: implicit node-gyp rebuild (binding.gyp) - REMOVED (yellow): a script that went away
- MODIFIED (red): same key, changed content, with a line-level diff
- PROVENANCE IDENTITY CHANGED (red): the attested build identity moved, see below
diff also resolves each version's provenance identity and compares it. A version built from acme/widget .github/workflows/release.yml@refs/tags/v1.2.0 upgrading to one built from .github/workflows/hotfix.yml@refs/heads/main prints:
PROVENANCE IDENTITY CHANGED workflow .github/workflows/release.yml β .github/workflows/hotfix.yml, ref refs/tags/v1.2.0 β refs/heads/main
and exits 1, the same gate as an added or modified install script: the workflow that produced the artifact is not the one that produced the version you already trust. Provenance appearing or disappearing across the upgrade gates too. A new commit alone never does (every release has one), and an identity that cannot be resolved on either side is never compared, so registry hiccups cannot fail your CI. When the identity is present and unchanged, a green UNCHANGED: provenance identity β¦ line says so.
binding.gyp is compared by content, not by existence (fixed in 1.3.0). A
version that keeps its build file but rewrites it changes what runs at
install time, and used to slip through as UNCHANGED / exit 0, the shape the
June 2026 wave-2 releases had. Now:
$ npx npm-script-lens diff bufferutil@4.0.8 bufferutil@4.0.9
bufferutil@4.0.8 β bufferutil@4.0.9
UNCHANGED: install
MODIFIED: binding.gyp (implicit node-gyp rebuild, contents changed)
{
+ 'variables': {
+ 'openssl_fips': ''
+ },
'targets': [
β¦
$ echo $?
1
--json carries { gyp: { changed, gainedChannels } }; gainedChannels lists
gyp execution channels present in the new version and absent from the old (here
it is empty, a benign build-file edit, no new way to run a command). It also
carries { provenance: { changed, changes, old, new } } with the resolved
identity of both sides.
Exit 0 when everything is unchanged; exit 1 the moment any script is added or modified or the provenance identity changed, so a Renovate/Dependabot CI step can fail the moment an upgrade grows its install-time surface. (A pure removal stays exit 0.)
npm v12 doesn't just gate install scripts, it also stops resolving git
dependencies (github:user/repo, git+ssh://β¦) and remote tarball URLs
(https://β¦/pkg.tgz) unless you opt in via allow-git / allow-remote in
.npmrc. Both are the strict enum all | none | root (default none);
root allows only deps declared in your root package.json, so a single
transitive git dep forces all. There's no migration tooling upstream, the
official discussion's
best offer is grep -r 'git+' package.json. sources does the whole job:
npx npm-script-lens sources # report + the minimal correct .npmrc
npx npm-script-lens sources --check # CI: exit 1 on insufficient, over-permissive, or invalid config
npx npm-script-lens sources --write # merge the minimal values into .npmrc (comment-preserving)
npx npm-script-lens sources --json # { git, remote, npmrc }Real output for a project with a root-declared git dep and a transitive one:
git dependencies (2)
ROOT left-pad @ github:left-pad/left-pad
TRANSITIVE some-pkg @ git+ssh://git@github.com/a/b.git via my-lib -> some-pkg
remote dependencies (0)
minimal correct .npmrc:
allow-git=all
allow-git=all is required because 1 git dependency is transitive; allow-git=root would otherwise suffice.
Re-point or drop `some-pkg` (via my-lib) to tighten this to allow-git=root.
It reads all four lockfile dialects (package-lock v1/v2/v3, yarn classic +
berry, pnpm, bun.lock) with zero network calls, and --check fails in
three distinct ways so CI tells you what to do:
- insufficient: npm v12 will refuse the install (missing
.npmrc, orrootcommitted while a transitive git dep exists); - over-permissive:
allcommitted whereroot(or nothing) suffices: the least-privilege ratchet; - invalid:
allow-git=trueor a bare--allow-git, which several published migration guides recommend, is not in the enum: npm treats it as unset and your install still breaks. The check names the valid three.
allow --ci-check folds the insufficient/invalid cases into its fast CI gate
too, and doctor reports whether your npm has the keys at all (they appeared
in 11.10.0 / 11.15.0) and warns that allow-git=root is unreliable on npm 11
(npm/cli#9189, closed via PR #9206,
root-level git deps were wrongly rejected): prefer all there. The .npmrc
emitter is npm-only; for yarn/pnpm/bun lockfiles the dependency report still
works, the write is skipped with a note.
Every other check here asks what does this package do?. Cooldown asks only how old is this version?
On 2026-08-04 attackers compromised the keyv maintainer's GitHub account
(~127M weekly downloads) and pushed the Mini Shai-Hulud worm into keyv,
cacheable, flat-cache and file-entry-cache. It spread to nine unrelated
organisations in about half an hour and 400+ packages that day, and, like
every worm before it, was identified and unpublished within hours. The install
that hurts you is the one that lands inside that window.
So don't go first.
# fail the build if any locked version is younger than 72h (the default)
npm-script-lens audit --cooldown
# stricter, or looser
npm-script-lens audit --cooldown 24
# ship a genuine same-day fix anyway
npm-script-lens audit --cooldown --cooldown-allow hotfix-pkg@2.0.1β cooldown 72h: 1 package version(s) published too recently:
keyv@5.5.5 6.0h old (clears 2026-08-07 14:00Z)
These may be perfectly fine. Cooldown does not inspect them. It declines to be
among the first to install a version, because npm worms are typically caught
within hours. Wait, pin to an older version, or exempt with --cooldown-allow.
Notes:
- It is opt-in. Without the flag, nothing changes.
- Age comes from the absolute publish timestamp at evaluation time, never from a cached day-resolution age, a stale cache errs toward older, which would fail open on precisely the young version this is meant to catch.
- Packages with no publish date (
--offline, private registries) are listed as unchecked rather than blocked. - Because a poisoned version doesn't need a lifecycle hook to hurt you,
--cooldownfetches publish dates for every locked package, not just the ones with install scripts. That is more registry traffic than a plain audit.
The malicious keyv@6.0.0 of 2026-08-04 carried a valid npm attestation
naming GitHub Actions as its trusted publisher.
Snyk's teardown
draws the boundary in one sentence: "Provenance can faithfully attest a build
whose source or workflow context has already been compromised." A green
provenance β on its own is therefore close to worthless as a trust signal,
and this tool no longer stops there. When a version carries an attestation,
the audit resolves what it actually claims, from the registry's
attestation endpoint (the SLSA v1 predicate): the source repository, workflow
path, ref, commit and builder. The trust line in every report reads:
2.1y old Β· 127M dl/wk Β· 1 maintainer Β· provenance β github.com/jaredwray/keyv .github/workflows/release.yml@refs/heads/main 4a91c0e
and provenance β (identity unavailable) when the registry's answer is not a
shape this build can resolve. The identity rides the same 24h trust cache, and
--offline / --no-trust make zero attestation requests.
What we read, and what we do not verify. This tool reads the claims the registry serves over TLS and does not verify Sigstore signatures or transparency-log inclusion. That is the same trust boundary as the tarball itself: if you trust the registry to hand you the package bytes, these are the claims it hands you alongside them. Nothing here should be described as cryptographic verification, because it is not.
Honesty about what this catches. ChainDrop's attacker published through
each project's own repository and own release workflow, so the attested
identity matched perfectly and this feature would not have caught it.
Neither would it have caught the TanStack compromise three months earlier
(2026-05-11, 84 malicious versions across 42 @tanstack/* packages, the first
worm to ship validly-attested malicious packages). There, a
pull_request_target workflow that publishes nothing poisoned a shared pnpm
cache; release.yml restored it on main; the payload read the OIDC token out
of the runner's memory and posted straight to the registry. The
postmortem
is explicit about what an identity check would have seen: "The token's
attested identity still matched TanStack/router release.yml@refs/heads/main."
Worth noting that this tool's own DANGEROUS gate
would have missed it too, since it flags a publish path reachable from
pull_request_target and that publish path was not reachable from one. The
link was a shared cache scope, not the trigger graph.
The defence in this tool for both events is --cooldown,
and the window is narrow enough for it to work: TanStack's malicious versions
were publicly identified within 20 to 26 minutes.
What identity resolution does catch: an attested repository that disagrees
with the package's declared repository, and a build identity that moves
between the version you trust and the version you are installing, the
diff gate above
and the expectProvenance policy pin. Honesty about prior art, because there is real prior art here. npmjs.com's
package page already shows Build Environment, Build Summary, Source Commit and
Build File for a provenance-carrying version. npm audit signatures checks
registry signatures and attestations. And
cosign already pins an
expected identity, cryptographically, which this tool does not do:
cosign verify-blob-attestation --bundle npm-provenance.sigstore.json --new-bundle-format \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
--certificate-identity-regexp="^https://github.com/npm/node-semver/.github/workflows/release-integration.yml.?" \
semver-7.6.3.tgzIf you need cryptographic assurance for one package, use that, not this. What those tools do not do is work at tree scale: cosign verifies one artifact against one bundle you already fetched, and the npmjs.com page covers one package you are already looking at. This tool resolves the identity for every dependency in one CI run, diffs it across an upgrade, and expresses the expectation as checked-in policy rather than a regex in a shell command. That is the gap it fills, and it is narrower than "we surface provenance identity."
Two more surfaces carry it: audit --diff/--since reports
provenance identity changed vs <base> per upgraded package next to the
capabilities-gained note (informational there, it never changes the audit exit
code), and SARIF gains provenance-identity-changed (warning) plus
provenance-repo-drift (note). The repo-drift note fires when the attestation
names a different owner/repo than the packument declares, with both values
spelled out. It is deliberately informational and never gates anything: npm
requires your package.json repository to match where you publish from with
provenance and re-checks the linked source when provenance is viewed, so a
live mismatch is almost always a repo rename or transfer since publish, not an
attack. Monorepo subpaths never count as drift. doctor reports whether the
attestation endpoint answered.
The other side of the same coin: npm-script-lens guards the install side of
your workflows; publish guards the publish side. The GitHub changelog of
2026-07-31
is explicit: "2FA-bypass tokens will also lose direct publish. Their
publishing surface will reduce to reading private packages and staging a
publish, which a maintainer approves with 2FA. We are targeting January 2027
for this update." Phase 1 already shipped on 2026-07-31, so publishing is the
last thing those tokens can still do. If your release workflow does
npm publish with NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}, it has an
expiry date.
npx npm-script-lens publish # classify every CI publish path + the migration patch
npx npm-script-lens publish --check # CI: exit 1 when a path uses a long-lived token or is BROKEN
npx npm-script-lens publish --json # { cliff, floors, counts, paths, repo, engines }
npx npm-script-lens publish --sarif f # rules publish-token-cliff + publish-oidc-broken, workflow-line anchoredIt reads .github/workflows/*.yml, .github/actions/**/action.yml,
.gitlab-ci.yml and .circleci/config.yml (no network, no YAML dependency),
finds npm publish, npm stage publish, pnpm publish, yarn npm publish,
np, semantic-release, changesets/action and JS-DevTools/npm-publish,
and classifies each path as exactly one of:
- TRUSTED: an id-token grant (
permissions: id-token: writeorwrite-all; GitLabid_tokenswith audiencenpm:registry.npmjs.org; CircleCINPM_ID_TOKEN) and no token: already on trusted publishing (OIDC), survives the cliff; - STAGED:
npm stage publish, where a maintainer approves with 2FA (npm stage approve <stage-id>), survives the cliff; - TOKEN:
NODE_AUTH_TOKEN/NPM_TOKENin the env, or an.npmrcwrite containing_authToken. Stops working around January 2027, and fails--check; - BROKEN: trusted publishing is granted, but the job's
actions/setup-nodeis v6 or older and passesregistry-url: https://registry.npmjs.org. Every setup-node release up to v6 answers that input by writing//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}into an.npmrcand exporting a dummyNODE_AUTH_TOKEN, so npm sees auth as configured and never starts the OIDC exchange, so the publish fails with ENEEDAUTH/E404 (npm/documentation#1960; fixed in setup-node v7.0.0). The official trusted-publishers docs still show the broken@v6recipe. Fails--check; the report gives all three fixes (bump to@v7, dropregistry-url:, orsed -i '/_authToken/d' "$NPM_CONFIG_USERCONFIG") and a job that already strips the line stays TRUSTED. An unresolvable setup-node ref (SHA pin, branch, expression) gets aβ οΈ note, never a downgrade; - UNKNOWN: a publish exists but the auth is ambiguous (third-party reusable workflows, both grant and token, neither visible): reported, never a failure.
A publish step hidden behind uses: ./.github/actions/release is followed
into the composite action itself (and into local reusable workflows, nested up
to 3 levels): the path anchors to the real action.yml line, an indented
via .github/workflows/release.yml:11 (job release, step "Release") line
shows the call chain, and the auth threads the way GitHub threads it:
composite actions cannot declare permissions, so the id-token grant is
always the calling job's, and a composite
env: NODE_AUTH_TOKEN: ${{ inputs.npm-token }} is resolved through the
calling step's with: map back to secrets.NPM_TOKEN. A publishing composite
under .github/actions/ that no workflow in the repo references is still
reported (UNKNOWN, since it may be called from another repo). Third-party actions
(actions/checkout@v4) are never flagged.
Then it does the three checks no migration blog performs:
- Version floors, verbatim from docs.npmjs.com: trusted publishing
"requires npm CLI version 11.5.1 or later and Node version 22.14.0 or
higher"; staged publishing "requires npm CLI version 11.15.0 or later and
Node version 22.14.0 or higher." A
setup-nodepin (or anengines.nodeminimum) below the floor is called out with exactly which fix it blocks. Migrating a workflow that's pinned to Node 20 fails at the first publish, so the pin bump is part of the fix. - Runner eligibility. Trusted publishing supports only GitHub-hosted
runners, GitLab.com shared runners and CircleCI cloud: "Self-hosted
runners are not currently supported but are planned for future releases."
A
runs-on: self-hostedpublish job gets trusted publishing marked UNAVAILABLE and is routed to the one path that survives there:npm stage publish+npm stage approve <stage-id>. - The npmjs.com side, pre-filled. Trusted publishing also needs config on
npmjs.com;
publishemits the settings checklist filled in from your repo (org/user, repository, workflow filename with its extension, the environment name if the job declares one, and the allowed actions).
Real output for a workflow publishing with a token on Node 20:
publish paths (1)
TOKEN .github/workflows/release.yml:15 npm publish [job release Β· ubuntu-latest]
long-lived token: NODE_AUTH_TOKEN in the publish step env (line 17)
β 1 TOKEN publish path. Direct token publishing stops working around January 2027.
fix for .github/workflows/release.yml:15, switch to trusted publishing (OIDC):
add to the `release` job (or the workflow top level) in .github/workflows/release.yml:
+ permissions:
+ id-token: write
remove the token from the publish step (line 17):
- env:
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
β οΈ node-version 20 (.github/workflows/release.yml:12) is below the Node 22.14.0 floor β¦
npmjs.com trusted-publisher settings (package Settings β Trusted publisher):
GitHub organization or user: acme
repository: widget
workflow filename: release.yml (with its extension, exactly as on disk)
environment: (the job declares none, leave blank)
allowed actions: npm publish (also allow "npm stage publish" if you plan to stage releases)
doctor reports the same readiness mix (naming the setup-node ref on a
BROKEN path), and the GitHub Action's publish-check input
fails the job with an ::error and a publish-token-cliff /
publish-oidc-broken SARIF result when a TOKEN or BROKEN path remains.
Auth answers "will this path publish after January 2027". It does not answer
the ChainDrop question. On 2026-08-04 that worm published 2,234 poisoned
versions across 444 npm package names by taking over a maintainer's GitHub
account and letting each project's own release workflow build, sign and
publish, with valid provenance, because the authorized build system produced
it. So publish now reports two more facts per resolved path: the trigger
(which workflow events reach the job, with file:line) and the gate (what
human action, if any, stands between a commit and npm publish):
- DANGEROUS: reachable from
pull_request_targetorworkflow_run. crates.io removed both from Trusted Publishing (development update, 2026-01-21): "Both triggers have been involved in past CI security incidents, where attackers exploited workflow permissions to escalate access or obtain publishing credentials." Fails--checkon its own. - REVIEWABLE: the job declares
environment:, the one hook GitHub offers for required reviewers. PyPI's security model: "Dedicated environments allow for additional protections like required reviewers, which can be used to require manual approval for a workflow using the environment." - MANUAL: only
workflow_dispatchand/orreleasereach the job. - TAG:
pushwith a tags-only filter. - AUTO: a branch push, bare push, schedule or pull_request, with no environment. Any commit that lands publishes.
- UNKNOWN: not determinable (e.g.
on: workflow_callwith no caller in this repo). Never affects the exit code.
The worked example, a release workflow publishing on every merge to main:
publish paths (1)
TRUSTED .github/workflows/release.yml:22 npm publish [job release Β· ubuntu-latest]
trusted publishing (OIDC): id-token: write granted (line 9), no
token in the env
trigger: push β branches: [main] (.github/workflows/release.yml:3)
gate: AUTO: any commit that lands on main publishes to npm. The
job declares no environment:, so GitHub cannot require an
approval.
fix: add `environment: release` to job "release" (line 11) and
set required reviewers on it (PyPI: "Dedicated environments
allow for additional protections like required reviewers");
or move to `on: release: types: [published]`; or publish
with `npm stage publish` + `npm stage approve <stage-id>`.
(Reports reflow to your terminal width, so the wrapping above is what an 80-column terminal shows. Piped or redirected output is not reflowed.)
Add environment: release to that job and the gate reads REVIEWABLE.
Honesty note: whether required reviewers are actually configured on an
environment is a repository setting, not a file, so this tool cannot verify
it; REVIEWABLE says "verify required reviewers are configured on it" rather
than implying it checked. Triggers thread through local reusable workflows
and composite actions (a path reached via .github/actions/release inherits
the calling workflow's on:). GitLab jobs classify from environment:,
when: manual and tag-only rules:/only:; CircleCI from an approval-type
job upstream in the same workflow; anything else stays UNKNOWN, never a
guess.
publish --check exits 1 on DANGEROUS (plus TOKEN and BROKEN, as before).
--require-gate <none|tag|manual|environment> (default none) raises the
bar: tag fails AUTO paths, manual also fails TAG, environment demands
REVIEWABLE. SARIF gains publish-dangerous-trigger (error) and, under
--require-gate, publish-ungated (warning), anchored to the trigger line.
To be clear about what is new here: zizmor's dangerous-triggers audit
already flags pull_request_target and workflow_run generically, in any
workflow. What zizmor (41 audits), poutine (13 rules) and octoscan do not do
is attribute a trigger and a human approval gate to a resolved npm publish
path, through composite actions and reusable workflows, with a
registry-precedent fix ladder. That attribution is this release.
npm-script-lens covers three moments where code runs without you asking:
install time (audit/allow), resolution time (sources), publish time
(publish). The 2026-08-04 keyv/ChainDrop worm used a fourth: open
time. Wiz's teardown
says it plainly: "Persistence is attempted via Claude Code hooks and VS Code
tasks.json", two separately-hashed setup.mjs payloads, one under
.claude, one under .vscode. And the tarball half predates the worm:
hijacked npm packages
html-to-gutenberg and fetch-page-assets (2026-05-25) shipped a hidden VS
Code task named eslint-check with "runOn": "folderOpen", firing when the
package directory itself is opened as a workspace.
npx npm-script-lens hooks # scan the working tree (monorepo subdirs included)
npx npm-script-lens hooks --check # CI: exit 1 at or above the --fail-on floor (default high)
npx npm-script-lens hooks --deps # also scan every locked dependency's tarball
npx npm-script-lens hooks --json # { findings, partial, caveats, deps }
npx npm-script-lens hooks --sarif hooks.sarif # rule hook-auto-run, anchored to the real file:lineTwo surfaces (one table row each in src/hooks.js, adding an editor is a
one-file patch):
.vscode/tasks.json: any task whoserunOptions.runOnis"folderOpen", with its label, command+args, type, and whetherpresentation.reveal: "silent"hides the terminal..claude/settings.json: a documented project-level, committable hooks location. Auto-firing events (SessionStart,Setup,InstructionsLoaded) tier at full strength; agent-triggered ones (PreToolUse,PostToolUse, β¦) are collected, tiered one level lower and labelled. The four non-command hook types (http,mcp_tool,prompt,agent, withcommand, the complete documented set) are reported, but never as command execution.
Both files permit comments and trailing commas, so the reader is a tolerant
JSONC parser in the same spirit as the gyp lens:
a file that will not parse is reported partial (with a raw-text hint when
folderOpen or an auto event name appears in the bytes), never passed
silently, never a crash. Every command string feeds the same shell-signal
extraction and score() that audit applies to a lifecycle script, the
risk ladder is not forked. Real output for a repo carrying both worm
artifacts:
.vscode/tasks.json:4 HIGH folderOpen task "eslint-check" β node .vscode/setup.mjs (silent)
.claude/settings.json:3 HIGH SessionStart hook β node .claude/setup.mjs
2 open-time execution entries found (2 HIGH).
--deps additionally downloads every locked dependency's tarball (cached,
like audit results) and scans it for shipped .vscode/.claude surfaces, a
folderOpen task inside a package is a payload, not a team convention, so it
is HIGH regardless of command.
The two surfaces gate very differently, and the tool prints each caveat next to its own surface rather than one softened blend:
VS Code: a
.vscode/tasks.jsonfinding means "this runs once you trust this folder and allow automatic tasks", not "this has run", VS Code 1.117 defaultstask.allowAutomaticTaskstooffwith a one-time Allow/Disallow prompt, workspace settings can no longer define that key, and automatic tasks never run in an untrusted workspace. The residual gap (microsoft/vscode#309406) is that the prompt does not display the command it is about to allow.Claude Code: a
SessionStartfinding means "this runs on your next session in this trusted folder", there is no hook review gate before a project.claude/settings.jsoncommand hook fires ("Claude Code doesn't use the same hook review gate as Codex", Datadog Security Labs, 2026-08).
Interpolations (${workspaceFolder}, ${CLAUDE_PROJECT_DIR}) are kept
literal, never resolved. .claude/settings.local.json is out of scope, it
is machine-local and gitignored, so it neither ships in a tarball nor arrives
with a clone.
npm v12's own pending list stops at the script command:
$ npm approve-scripts --allow-scripts-pending
sharp@0.33.5 install: node install/check
What's inside install/check? npm can't tell you, the #1 complaint in the v12 migration discussion. review picks up exactly where npm stops:
npx npm-script-lens review # show every pending approval with evidence
npx npm-script-lens review --output-allowscripts # β¦and write the decisions into package.jsonFor each package awaiting an allowScripts decision it shows the script command, the first 40 lines of the actual file the command runs (from the version-pinned registry tarball, or node_modules with --offline), the behavioral scan verdict with signals, the OSV malware check, and publisher trust:
ββ sharp@0.33.5 [π΄ HIGH]
1.9y old Β· 75M dl/wk Β· 1 maintainer Β· no provenance
OSV: no known malicious advisories
install: node install/check
exec: node-gyp rebuild --directory=src
exec: require('child_process')
ββ install/check.js (first 40 of 42 lines)
β 1 // Copyright 2013 Lovell Fuller and others.
β 2 // SPDX-License-Identifier: Apache-2.0
β¦
The pending set comes from your own npm when it can answer: with npm β₯ 12, review runs npm install --dry-run --json and reads its unreviewedScripts, so what you review is literally what npm would block, even before a lockfile exists. On npm < 12 (or --offline) it computes the same set from the lockfile minus your allowScripts entries (bare-name and pinned keys both count, and false is a decision too, matching npm v12's semantics exactly).
--output-allowscripts merges version-pinned entries for every reviewed package into package.json, preserving existing decisions: SAFE/LOW default to true, HIGH/MEDIUM and OSV-flagged packages to false, so flip after reading the evidence. --json emits the whole review (pending, risk, content, suggested block) for scripting. NPM_SCRIPT_LENS_NPM overrides which npm the dry-run uses.
allow runs the scan and splits every package that has install-time scripts into two buckets, the ones behavioral analysis found harmless (SAFE/LOW) go straight into the allowlist; everything that spawns processes, reaches the network, is known-malicious, or couldn't be fetched (MEDIUM/HIGH) is held back in a _review list for a human. It emits the block in your package manager's native format, auto-detected from the lockfile, on stdout, with a one-line summary on stderr:
npx npm-script-lens allow # scan, print the native allowlist block + _review
npx npm-script-lens allow --write # β¦and merge the auto-approved entries into the right file
npx npm-script-lens allow --manager pnpm # force a manager instead of auto-detecting
npx npm-script-lens allow --input audit.json # classify a saved `audit --json` result, no rescanEvery major package manager adopted the same "scripts are opt-in, keep an allowlist" model. allow writes each one's native format: same risk policy, same analysis, different file:
| manager | allowlist | file | allow --write target |
|---|---|---|---|
| npm 12 | allowScripts: { "pkg@1.2.3": true } |
package.json | package.json |
| pnpm 10.26+/11 | allowBuilds: { pkg: true } |
pnpm-workspace.yaml | pnpm-workspace.yaml (comment-preserving) |
| yarn Berry | dependenciesMeta.<pkg>.built: true |
package.json | package.json + enableScripts: false in .yarnrc.yml |
| bun | trustedDependencies: ["pkg"] |
package.json | package.json |
1 package auto-approved, 1 need manual review. (pnpm, allowlist in pnpm-workspace.yaml) β stderr
bun caveat (surfaced automatically): defining
trustedDependenciesreplaces bun's built-in trusted list, so packages bun trusted by default (esbuild, sharpβ¦) stop running scripts unless listed. yarn needsenableScripts: falseto turndependenciesMetainto an allowlist, andallow --writesets it for you.
allow --ci-check runs no scan, it's a fast gate for CI. It exits 1 when all three are true: a workflow in .github/workflows/ runs npm install/npm i/npm ci, package.json has no allowScripts block, and the local npm is v12+ (probed via npm --version). That is exactly the combination where npm v12 will silently skip every dependency's install scripts and your build breaks with no obvious cause.
npx npm-script-lens allow --ci-check
# CI will break on npm v12: run lens allow to generate allowScripts block. (exit 1)Any one of those conditions being false, npm < 12, an existing allowScripts block, or no npm install in CI, passes with a one-line reason. To fix a failing check, run allow --write: it writes the auto-approved entries and leaves the _review packages out (writing them would be deciding for you, so they stay pending until a human looks).
By default allow/review/sync auto-approve SAFE/LOW behavioral risk. A script-lens.policy.json in the project root (or --policy <file>) turns that fixed heuristic into a team decision:
{
"autoApprove": {
"maxRisk": "LOW", // approve up to this risk (SAFE|LOW|MEDIUM|HIGH)
"denyCapabilities": ["net"], // never auto-approve a script that reaches the networkβ¦
"minAgeDays": 30, // β¦or a version published < 30 days ago (needs trust data)
"requireProvenance": false, // β¦or one without a provenance attestation
"expectProvenance": { // pin the attested build identity per package
"keyv": "jaredwray/keyv:.github/workflows/release.yml"
}
},
"waivers": {
"sharp": { "allow": true, "reason": "vetted native build", "expires": "2027-01-01" }
}
}Waivers are explicit human decisions that override the heuristic until they expire, an auditable record of why a risky package was trusted. With no policy file present, behavior is exactly the built-in default.
Be clear about what requireProvenance buys you: presence alone is not a
trust signal. The malicious keyv@6.0.0 had valid provenance, and requiring
it would have approved that release; --cooldown
is the defence for that event. expectProvenance is the stronger form: it
pins the attested identity to
"owner/repo" or "owner/repo:workflow-path". A package whose attestation
does not match its pin, or whose identity cannot be resolved, is never
auto-approved, with the reason naming both the expected and the actual
identity. Packages without a pin are unaffected.
Version-pinned npm entries are silently invalidated by every dependency bump; name-keyed managers (pnpm/yarn/bun) drift as packages come and go. sync reconciles your manager's native allowlist with the lockfile, auto-detected and written in the right format:
npx npm-script-lens sync --check # CI: exit 1 when the allowlist drifted
npx npm-script-lens sync --write # drop stale entries, add new scripted packages,
# (npm) re-pin upgrades, PRESERVING decisions when
# the new version gained no capabilities
npx npm-script-lens review --output-allowscripts # review pending WITH script content, then write
npx npm-script-lens approve # step through risky packages interactively (npm)npx npm-script-lens init # scaffold script-lens.policy.json + a CI workflowinit writes a starter policy and a ready-to-commit GitHub Action (audit + allow --ci-check gate), skipping anything that already exists (--force to overwrite). Add --auto-fix for a Renovate/Dependabot bot workflow, or --hook to install a git pre-commit hook that runs sync --check. Prefer the pre-commit framework? This repo ships a .pre-commit-hooks.yaml.
npm v12's own tooling has two known bugs that leave teams with a green approve-scripts run and a red npm ci:
- Optional dependency gap (npm/cli#9562):
npm approve-scripts --allow-scripts-pendingnever lists optional dependencies, butnpm ci --strict-allow-scriptsstill rejects any optional dep with install scripts that is missing fromallowScripts. The classic trap wasfsevents: it only installs on macOS, so on a Linux CI runner nothing surfaced it, and strict mode failed the build anyway. This one is fixed upstream, PR #9597 (merged 2026-06-23) makes the strict check skip inert nodes, since reify removes them before install scripts run; it shipped in npm 11.18.0 and is in npm 12.0.0. So the detector is version-gated: on an npm carrying the fix it drops optional deps whoseos/cpuexclude your platform (!-negated entries honored) and reports only the ones that really would install here; on an older npm nothing changes. The report tells you which npm it checked and the version the bug was fixed in. - EGLOBAL in global installs (npm/cli#9463): when
npm install -g <pkg>warns about unreviewed install scripts, the suggestednpm approve-scriptscommand errors withEGLOBAL, and there is no post-install approval path in global contexts. The working form is allowing at install time:npm install -g --allow-scripts=<pkg> <pkg>.
npx npm-script-lens audit --check-v12-gaps # markdown report
npx npm-script-lens audit --check-v12-gaps --json # { findings: [...] }
npx npm-script-lens audit --check-v12-gaps --sarif v12.sarifThe first check reads optional + hasInstallScript from your package-lock.json, resolves the actual script names from registry metadata, and flags every optional dep with install scripts that your allowScripts block doesn't cover (bare-name and version-pinned keys both count as decisions). The second scans .github/workflows/*.yml for npm install -g / npm i -g lines, checks each installed package's registry metadata for install scripts, and flags the ones without an --allow-scripts guard, anchored to the exact workflow file and line. Findings are severity warn and never fail the run; packages the registry can't confirm are skipped rather than guessed (except when the lockfile itself says hasInstallScript, which is trusted even if the registry is unreachable).
In the GitHub Action this runs as a separate step controlled by check-v12-gaps (default auto: runs only when the runner's npm is v12+). It writes to the job summary, emits ::warning annotations, and merges its findings into the SARIF file from the main audit step so code scanning shows them too.
The strongest review signal is a diff a human already reads: the PR diff itself. manifest writes a stable, minimal receipt of install-time behavior, sorted name@version β capability kinds, that you commit next to your lockfile. When a dependency change alters what install scripts can do, the git diff of that file is the approval-surface change, reviewable with zero tooling:
npx npm-script-lens manifest --write # writes script-lens.json next to the lockfile
npx npm-script-lens manifest --check # CI: exit 1 if behavior drifted from the committed file{
"tool": "npm-script-lens",
"version": "0.4.0",
"packages": {
"sharp@0.33.5": { "risk": "HIGH", "capabilities": ["env", "exec", "obf"] }
}
}It records behavior only, no download counts, publish age, or OSV status, so the file changes when a package's capabilities change, not when its popularity does (live malware/trust checks stay in audit). A bump in the version field means the detector itself changed and results are worth re-reviewing. In the Action, set manifest-check: 'true' to fail PRs that leave the manifest stale, with the drift written to the job summary. (Requested by @raju_dandigam, thanks!)
npx npm-script-lens mcpRuns an MCP stdio server with three tools: audit_package (audit one package, before an agent adds it as a dependency), audit_lockfile, and classify_allowscripts (audit a lockfile and return the allow split ({allowScripts, _review}), so an agent can generate the block non-interactively). Claude Code config:
{ "mcpServers": { "npm-script-lens": { "command": "npx", "args": ["npm-script-lens", "mcp"] } } }Real output for a project depending on sharp, prisma, core-js, chalk (39 locked packages, ~5s): see fixtures/demo-report.md. Highlights:
| package | script | risk | signals |
|---|---|---|---|
sharp@0.33.5 1.9y old Β· 74M dl/wk Β· 1 maintainer |
install | π΄ HIGH | exec: node-gyp rebuild --directory=src Β· exec: require('child_process') β¦ |
@prisma/engines@5.22.0 via prisma Β· 15M dl/wk Β· provenance β github.com/prisma/prisma .github/workflows/release-latest.yml@refs/heads/main 718358a |
postinstall | π΄ HIGH | net: require('@prisma/fetch-engine') Β· exec: require('execa') Β· fs: writeFileSync β¦ |
core-js@3.38.1 |
postinstall | π‘ LOW | fs: fs.writeFileSync Β· env: process.env |
chalk@5.3.0 |
β | π’ SAFE | no lifecycle scripts |
{
"allowScripts": {
"@prisma/engines@5.22.0": false,
"core-js@3.38.1": true,
"prisma@5.22.0": true,
"sharp@0.33.5": false
}
}name: audit-install-scripts
on: pull_request
permissions:
pull-requests: write
jobs:
lens:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: Booyaka101/npm-script-lens@v1
with:
path: '.' # dir or lockfile; npm/yarn/pnpm auto-detected
fail-on-high: 'true' # exit 1 when a HIGH-risk script appears
comment-on-pr: 'true' # post the report as a PR commentThe action writes the report to the job summary, comments on the PR (plain GitHub REST issues/comments call using GITHUB_TOKEN, the same endpoint octokit uses), and fails the job when fail-on-high is true and a HIGH package exists.
Optional inputs: diff-base (audit only packages added/upgraded vs a base lockfile, e.g. one extracted from the PR base branch), check-v12-gaps (auto/true/false, the npm v12 approve-scripts bug check, auto-enabled when the runner's npm is v12+), ci-check ('true' to enable, the allow --ci-check gate as a fail-fast Action step: fails the job when the runner's npm is v12+, a workflow runs npm install, and package.json has no allowScripts block, before the missing block silently breaks a downstream install), sources-check ('true' to enable, fails the job when the lockfile contains git or remote-URL dependencies the committed .npmrc allow-git/allow-remote doesn't correctly cover: insufficient, over-permissive, and invalid values all fail, with an ::error annotation and a job-summary line), publish-check ('true' to enable, fails the job when a CI publish path still authenticates with a long-lived npm token, which loses direct publish around January 2027, or is BROKEN by setup-node < v7 writing a dummy _authToken via registry-url; ::error annotation, a job-summary line naming the verdict, and a publish-token-cliff/publish-oidc-broken result merged into the audit's SARIF file), hooks-check ('true' to enable, fails the job when the working tree carries a HIGH open-time execution entry: a .vscode/tasks.json folderOpen task or an auto-firing .claude/settings.json command hook, with an ::error annotation, a job-summary section, and a hook-auto-run result merged into the audit's SARIF file), sync-check ('true', fails the job when the install-script allowlist has drifted from the lockfile; cross-ecosystem, auto-detects npm/pnpm/yarn/bun), and sarif-file for code scanning alerts:
- uses: Booyaka101/npm-script-lens@v1
with:
sarif-file: lens.sarif
- uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: lens.sarifnpm install --ignore-scripts
node src/cli.js audit --path fixtures/demo --fail-on-high
npm test # analyzer/lockfile/reporter units, offline mock-registry tests,
# live acceptance against the real registry, and a full action
# dry-run against a local mock GitHub APINode.js β₯ 18 (uses global fetch). No paid APIs: the public npm registry, plus the free OSV.dev and npm downloads APIs for trust enrichment (--no-trust or --offline to skip).
This tool's value is coupled to npm's own behavior: the allowScripts field, the unreviewedScripts shape in npm install --dry-run --json, the approve-scripts commands. Those will drift across npm releases, so npm-script-lens is built to notice when they do rather than fail silently:
npx npm-script-lens doctor # does this build still understand your npm?
npx npm-script-lens doctor --json # machine-readable, for scripts/CIdoctor probes your local npm and reports each contract assumption: version, allowScripts enforcement, a parser self-test, a live dry-run shape check, and each npm-bug detector's upstream status, then exits 1 on genuine drift (an npm output shape this build no longer recognizes). Under the hood:
- Every npm coupling lives in one file (
src/npm-contract.js), a future npm change is a one-line patch, not a hunt. reviewwarns loudly and falls back instead of silently trusting an unfamiliar npm answer as "nothing pending".- A scheduled npm-compat canary (
.github/workflows/npm-compat.yml) drives the real npm across12/latest/nexton a matrix and goes red on drift, the tripwire the unit tests (which use stub npms) can't be. - The two npm-v12 approve-scripts bug detectors are version-aware: the report says which npm it checked and links each bug's upstream status, so a detector can't quietly outlive the bug it was written for.
| code | meaning |
|---|---|
0 |
success (or findings that are warn-level only, e.g. audit --check-v12-gaps) |
1 |
an actionable failure: audit --fail-on-high found HIGH/malicious Β· sync --check/manifest --check drift Β· allow --ci-check would break on npm v12 Β· sources --check found insufficient/over-permissive/invalid allow-git/allow-remote config Β· publish --check found a TOKEN or BROKEN publish path (UNKNOWN never fails) Β· hooks --check found an open-time execution entry at or above the --fail-on floor Β· doctor detected npm drift Β· diff found an added/modified install script or a changed provenance identity |
2 |
a usage/runtime error (bad ref, missing lockfile, unreadable input) |
| command | does |
|---|---|
audit |
scan a lockfile, report install-script risk (Markdown/JSON/SARIF); --fail-on-high, --diff/--since, --check-v12-gaps |
allow |
split scripted packages into an auto-approved allowlist + _review, in your manager's native format; --write, --ci-check, --manager, --policy |
review |
show pending approvals with the actual script content + verdict; --output-allowscripts writes decisions |
diff |
compare a package's install scripts (+ implicit node-gyp) and provenance identity across two versions; exit 1 on any add/modify or identity change; --json |
sources |
git + remote-URL deps vs npm v12's allow-git/allow-remote: ROOT/TRANSITIVE per dep, minimal correct .npmrc; --check, --write, --json |
publish |
classify every CI publish path (TRUSTED/STAGED/TOKEN/BROKEN/UNKNOWN) vs npm's January-2027 token cliff and the setup-node < v7 OIDC breakage, with the migration patch + npmjs.com checklist; --check, --json, --sarif |
hooks |
the open-time surface: .vscode/tasks.json folderOpen tasks + .claude/settings.json hooks, same risk ladder as audit; --check, --fail-on, --deps (dependency tarballs, where shipped entries are HIGH regardless), --json, --sarif |
sync |
reconcile the native allowlist with the lockfile (drop stale, add new); --check for CI |
doctor |
is this build still in sync with your npm? contract probe + drift alarm |
init |
scaffold policy + CI workflow (--auto-fix bot, --hook git pre-commit) |
manifest |
committable behavior receipt whose git diff is the approval-surface change |
completion |
print a shell completion script (bash / zsh / fish) |
mcp |
MCP server for AI agents (audit_package, audit_lockfile, classify_allowscripts) |
The install-script-allowlist space has good tools, but each covers one slice:
| behavioral risk analysis | npm | pnpm | yarn | bun | writes native allowlist | policy / waivers | CI drift gate | MCP | |
|---|---|---|---|---|---|---|---|---|---|
| npm-script-lens | β exec/net/fs/obf + OSV + trust | β | β | β | β | β | β | β | β |
@lavamoat/allow-scripts |
β (allowlist mgmt only) | β | β | β | β | β | β | partial | β |
can-i-ignore-scripts |
β (lists scripts) | β | β | β | β | β | β | β | β |
native npm approve-scripts / pnpm approve-builds / bun pm trust |
β | one each | β | β | β | β |
The combination, behavioral evidence for the decision, in every manager's native format, with policy and CI enforcement, is what makes it the one tool to standardize on.
- Static capability detection, not proof of malice. A HIGH score means "this script can spawn processes", exactly the question to answer before approving, but plenty of HIGH packages (native builds) are legitimate. The lens gives evidence; you make the call. Only sandboxed execution could say more, and running untrusted install scripts to observe them is deliberately out of scope.
- Scripts invoking binaries from other packages (
husky install,patch-package) are resolved when a lockfile package with the same name owns the bin: that package's actual bin script is fetched, analyzed, and the row is re-scored on real evidence (bin: husky install β husky@9.1.7+ what the script actually does). Bins with no same-name owner in the lockfile stay conservatively HIGH asexec: β¦ (unresolved binary). - Helper dependencies: capability hidden inside helpers is caught via a curated list (
axios,got,undici,@prisma/fetch-engine, β¦) plus--deep, which follows barerequire()s from install-script code into the matching lockfile package's entry file (one level). A helper outside the lockfile, or loaded indirectly, can still slip a tier. - Obfuscation:
eval/new Function/vmand string-builtrequire()s score HIGH, and base64/char-code literal payloads are decoded and re-analyzed, so the report shows what the hidden code actually does, not just that it hides. Payloads assembled only at runtime (downloaded, decrypted, env-derived) remain opaque: flagged, not decoded. Plain variable indirection (require(someVar)) is deliberately not flagged, it's ubiquitous in bundler output.
- CLI:
npx npm-script-lens audit, npmjs.com/package/npm-script-lens - GitHub Action:
uses: Booyaka101/npm-script-lens@v1, releases - VS Code extension: inline install-script risk in
package.json,editors/vscode - Neovim plugin:
vim.diagnosticonpackage.json,editors/nvim - MCP server for AI agents:
npx npm-script-lens mcp - Pre-commit:
init --hookor the pre-commit framework Β· HTML report:audit --html report.html - Join the conversation: npm/rfcs#897 (allowScripts review-report RRFC) Β· npm v12 migration discussion
pnpm11-ci-guard, the other half of the build-script story on pnpm v11.
Once this tool writes an allowBuilds allowlist into pnpm-workspace.yaml, a
pnpm install inside Docker will prompt for approval and hang the build unless the
image sets ENV CI=true. pnpm11-ci-guard catches that, plus the rest of the v10 β v11
migration that lands in Dockerfiles and CI workflows: npm_config_* env vars that v11
silently stopped reading, and images that never COPY pnpm-workspace.yaml. pnpm's own
codemod covers neither.
Use npm-script-lens to decide what may build; use pnpm11-ci-guard to make sure your image and CI still work once you have decided.