[RFC] Make install scripts opt-in#868
Conversation
Block dependency install scripts (preinstall, install, postinstall, and auto-detected node-gyp builds) by default. Projects opt in to running scripts for specific dependencies via a new allowScripts field in package.json. Adds npm approve-scripts and npm deny-scripts commands. Revives RFC npm#488 (rejected 2021 as too disruptive). Supersedes RFC npm#861.
|
I'm sure this was considered by the team, but it would be great if they can provide insights: what is the rationale for keeping install scripts feature in the first place? In other words: why not get rid of this feature completely? |
|
@brunoborges The short answer is that a small but legitimate tail of packages still relies on install scripts ( The ecosystem has largely shifted to prebuilt binaries via The actual problem isn't that the feature exists, it's arbitrary execution from unaudited transitive deps, and that's addressable by making scripts opt-in with an explicit per-package allowlist. That's the direction this RFC takes, and it leaves room to tighten further in future majors. |
|
@leobalter I am afraid the RFC does not address the root of the problem that libraries are allowed to have install scripts in the first place. It still leaves room for laziness as an attack vector.
I'd argue that the existence of the feature is the actual problem. Once the feature exists, any enhancement is an additional guardrail. One approach to consider is to distinguish between executable tools and libraries as published packages. |
|
It would also be useful if With this output we can then edit the new "allowScripts" : [
"path/to/script.extension?sha256=foo"
]The current allowScripts proposal is still too permissive if approvals are scoped only to package names. A package can change its lifecycle scripts between versions — or even within the same version range — which means the developer may end up executing code that was never actually reviewed or approved. Because of that, approvals should be bound to the exact script file being executed, not just the package identity. |
A pure isScriptAllowed(node, policy) helper in
workspaces/arborist/lib/script-allowed.js. Used by the install-time
warning walker and by the approve-scripts / deny-scripts commands.
Matching rules follow the RFC:
- registry deps: name + optional semver (range or exact)
- git deps: canonical ssh-url match plus short-SHA prefix
- file / directory / remote tarball: exact resolved string match
- alias spec keys are ignored entirely; a user must address the real
package name, not the alias
- matching uses node.packageName, never node.name, so an alias
install cannot be approved by writing its alias name
Conflict resolution: any matching false wins over any matching true.
No match returns null (unreviewed).
Pure function, no I/O. 15 test cases cover alias safety and
omitLockfileRegistryResolved.
Refs: npm/rfcs#868
…tall scripts
The Phase 1 advisory warning. No scripts are blocked. After arb.reify()
completes, reify-finish walks the actual tree for dependencies whose
install-relevant lifecycle scripts are not yet covered by the
allowScripts policy. The result is appended to the install output as
one grouped block, not one log line per package.
- workspaces/arborist/lib/install-scripts.js: per-node helper that
returns the install-relevant lifecycle scripts. Covers preinstall,
install, postinstall, prepare (non-registry sources only), and the
synthetic 'node-gyp rebuild' detected by isNodeGypPackage from
@npmcli/node-gyp. The runtime fs check is needed because the
lockfile's hasInstallScript field misses packages whose only
install-time work is binding.gyp.
- lib/utils/check-allow-scripts.js: walks arb.actualTree.inventory
and filters to unreviewed nodes. Honours --ignore-scripts and
--dangerously-allow-all-scripts as full opt-outs. Treats explicit
deny entries as reviewed (no warning).
- lib/utils/reify-finish.js: runs the walker and passes results to
reify-output as an extras payload.
- lib/utils/reify-output.js: prints the grouped summary after the
funding and audit messages. JSON output puts the same data on
summary.unreviewedScripts.
Refs: npm/rfcs#868
Both share an implementation in lib/utils/allow-scripts-cmd.js; the
files in lib/commands/ are thin shims that set verb = 'approve' or
'deny'.
- npm approve-scripts <pkg>: writes 'pkg@version': true (pinned)
- npm approve-scripts --no-pin <pkg>: writes 'pkg': true (name-only)
- npm approve-scripts --all: approves every unreviewed install-script
package in the resolved actual tree
- npm approve-scripts --pending: read-only walk, lists unreviewed
packages without modifying package.json
- npm deny-scripts <pkg>: writes 'pkg': false. Always name-only,
regardless of --pin, per the RFC's asymmetric-pin rule.
- npm deny-scripts --all: denies every unreviewed install-script
package
The shared writer in lib/utils/allow-scripts-writer.js implements the
RFC's pin-mismatch table as pure functions:
applyApprovalForPackage(existing, nodes, { pin }) and
applyDenyForPackage(existing, nodes). Grouping by package matters
because a per-node API can't tell a stale pin from a newly installed
version; a per-package one can.
Two new flags registered in workspaces/config/lib/definitions
(--all reuses the existing global definition):
- --pending: read-only mode for approve-scripts
- --pin: control pin behaviour for approve-scripts (default true)
Includes docs/lib/content/commands/{npm-approve-scripts,npm-deny-scripts}.md.
Refs: npm/rfcs#868
The allowScripts policy must live at the project root. A non-root workspace declaring its own allowScripts field is almost always a mistake: that policy would be silently ignored at install time. reify-finish now walks the resolved actual tree after reify completes and emits one warning per non-root workspace whose package.json has an allowScripts field. Pure detection lives in lib/utils/warn-workspace-allow-scripts.js; the tree walk piggybacks on the inventory that's already loaded for the unreviewed-scripts summary. Refs: npm/rfcs#868
@AllanOricil I like this idea, but I think it's orthogonal to the rest of your proposal. A dry-run view of "here's what would actually run, with paths resolved through symlinks" is a useful diagnostic on its own, however approvals end up scoped. Worth its own RFC IMO.
The part I'm not sold on is hashing the script file itself. Lifecycle script values in
Agreed when it's name-only. The RFC's answer is that you can pin a version:
Pinned entries handle the version-to-version case, range keys like
|
|
There's a Phase 1 implementation up at npm/cli#9360 for anyone who wants to look at it alongside the RFC. It's advisory-only. Scripts still run, but install ends with a grouped warning listing packages whose install scripts haven't been approved via Reviews on either thread welcome. |
|
+1 on having something around that |
|
@JamieMagee One case I was thinking about that name@version + lock file integrity does not fully cover is when lifecycle scripts fetch remote content dynamically. For example, a postinstall script may download an executable from a stable URL: {
"postinstall": "node scripts/install.js"
}where install.js does something like: https.get("https://example.com/tool/latest")In that scenario:
So name@version + integrity guarantees the integrity of the npm package itself, but not necessarily the integrity of the code ultimately executed during install. That is partly why I was thinking about approvals at the executable/script level rather than only at the package level, although I agree it still would not completely solve dynamic remote execution. |
|
I will open a RFC for the other idea of outputing the list of all lifecycle scripts that would have run, if that isn't shown already. Maybe that info should be displayed as a warning, in a parsable way. We could use the output to manually analyze each executable or use a consensus of many LLMs analysis to better warn developers. Maybe NPM should do this LLM analysis at publish time before making a package available? |
…low-all-scripts configs
Three new configs to support the install-script opt-in policy. None
of them affect install behaviour yet; they're read by approve-scripts,
deny-scripts, and the install-time walker in later commits.
- allow-scripts: comma-separated package list. Used as a fallback
when the root package.json has no allowScripts field. Flattens
to flatOptions.allowScripts.
- strict-script-builds: boolean. Reserved for a future release that
will turn blocked-script warnings into errors. No-op for now.
- dangerously-allow-all-scripts: boolean escape hatch for that same
future release. No-op for now.
Refs: npm/rfcs#868
…I configs
A precedence resolver reads the install-time allowScripts policy from
the layered sources and threads it through install/ci into arborist.
- lib/utils/resolve-allow-scripts.js: pure resolver. Reads from
npm.prefix so workspace sub-installs still pick up the project
root. Returns { policy, source }. Strict fallback: package.json
wins over flat config; lower layers are silently ignored, with
one warn when a lower setting is being suppressed.
- install.js / ci.js: await the resolver before constructing
arborist opts, then pass policy through opts.allowScripts. Add
the three new params to each command's static params list.
- workspaces/arborist/lib/arborist/index.js: accept
options.allowScripts and store it on this.options. No enforcement
yet; read in later commits.
Also tightened the flatten function for the new allow-scripts config:
nopt wraps single comma-separated strings in arrays for [String, Array]
types, so each array entry needs splitting on commas before use.
Refs: npm/rfcs#868
A pure isScriptAllowed(node, policy) helper in
workspaces/arborist/lib/script-allowed.js. Used by the install-time
warning walker and by the approve-scripts / deny-scripts commands.
Matching rules follow the RFC:
- registry deps: name + optional semver (range or exact)
- git deps: canonical ssh-url match plus short-SHA prefix
- file / directory / remote tarball: exact resolved string match
- alias spec keys are ignored entirely; a user must address the real
package name, not the alias
- matching uses node.packageName, never node.name, so an alias
install cannot be approved by writing its alias name
Conflict resolution: any matching false wins over any matching true.
No match returns null (unreviewed).
Pure function, no I/O. 15 test cases cover alias safety and
omitLockfileRegistryResolved.
Refs: npm/rfcs#868
…tall scripts
The Phase 1 advisory warning. No scripts are blocked. After arb.reify()
completes, reify-finish walks the actual tree for dependencies whose
install-relevant lifecycle scripts are not yet covered by the
allowScripts policy. The result is appended to the install output as
one grouped block, not one log line per package.
- workspaces/arborist/lib/install-scripts.js: per-node helper that
returns the install-relevant lifecycle scripts. Covers preinstall,
install, postinstall, prepare (non-registry sources only), and the
synthetic 'node-gyp rebuild' detected by isNodeGypPackage from
@npmcli/node-gyp. The runtime fs check is needed because the
lockfile's hasInstallScript field misses packages whose only
install-time work is binding.gyp.
- lib/utils/check-allow-scripts.js: walks arb.actualTree.inventory
and filters to unreviewed nodes. Honours --ignore-scripts and
--dangerously-allow-all-scripts as full opt-outs. Treats explicit
deny entries as reviewed (no warning).
- lib/utils/reify-finish.js: runs the walker and passes results to
reify-output as an extras payload.
- lib/utils/reify-output.js: prints the grouped summary after the
funding and audit messages. JSON output puts the same data on
summary.unreviewedScripts.
Refs: npm/rfcs#868
Both share an implementation in lib/utils/allow-scripts-cmd.js; the
files in lib/commands/ are thin shims that set verb = 'approve' or
'deny'.
- npm approve-scripts <pkg>: writes 'pkg@version': true (pinned)
- npm approve-scripts --no-pin <pkg>: writes 'pkg': true (name-only)
- npm approve-scripts --all: approves every unreviewed install-script
package in the resolved actual tree
- npm approve-scripts --pending: read-only walk, lists unreviewed
packages without modifying package.json
- npm deny-scripts <pkg>: writes 'pkg': false. Always name-only,
regardless of --pin, per the RFC's asymmetric-pin rule.
- npm deny-scripts --all: denies every unreviewed install-script
package
The shared writer in lib/utils/allow-scripts-writer.js implements the
RFC's pin-mismatch table as pure functions:
applyApprovalForPackage(existing, nodes, { pin }) and
applyDenyForPackage(existing, nodes). Grouping by package matters
because a per-node API can't tell a stale pin from a newly installed
version; a per-package one can.
Two new flags registered in workspaces/config/lib/definitions
(--all reuses the existing global definition):
- --pending: read-only mode for approve-scripts
- --pin: control pin behaviour for approve-scripts (default true)
Includes docs/lib/content/commands/{npm-approve-scripts,npm-deny-scripts}.md.
Refs: npm/rfcs#868
The allowScripts policy must live at the project root. A non-root workspace declaring its own allowScripts field is almost always a mistake: that policy would be silently ignored at install time. reify-finish now walks the resolved actual tree after reify completes and emits one warning per non-root workspace whose package.json has an allowScripts field. Pure detection lives in lib/utils/warn-workspace-allow-scripts.js; the tree walk piggybacks on the inventory that's already loaded for the unreviewed-scripts summary. Refs: npm/rfcs#868
A pure isScriptAllowed(node, policy) helper in
workspaces/arborist/lib/script-allowed.js. Used by the install-time
warning walker and by the approve-scripts / deny-scripts commands.
Matching rules follow the RFC:
- registry deps: name + optional semver (range or exact)
- git deps: canonical ssh-url match plus short-SHA prefix
- file / directory / remote tarball: exact resolved string match
- alias spec keys are ignored entirely; a user must address the real
package name, not the alias
- matching uses node.packageName, never node.name, so an alias
install cannot be approved by writing its alias name
Conflict resolution: any matching false wins over any matching true.
No match returns null (unreviewed).
Pure function, no I/O. 15 test cases cover alias safety and
omitLockfileRegistryResolved.
Refs: npm/rfcs#868
…tall scripts
The Phase 1 advisory warning. No scripts are blocked. After arb.reify()
completes, reify-finish walks the actual tree for dependencies whose
install-relevant lifecycle scripts are not yet covered by the
allowScripts policy. The result is appended to the install output as
one grouped block, not one log line per package.
- workspaces/arborist/lib/install-scripts.js: per-node helper that
returns the install-relevant lifecycle scripts. Covers preinstall,
install, postinstall, prepare (non-registry sources only), and the
synthetic 'node-gyp rebuild' detected by isNodeGypPackage from
@npmcli/node-gyp. The runtime fs check is needed because the
lockfile's hasInstallScript field misses packages whose only
install-time work is binding.gyp.
- lib/utils/check-allow-scripts.js: walks arb.actualTree.inventory
and filters to unreviewed nodes. Honours --ignore-scripts and
--dangerously-allow-all-scripts as full opt-outs. Treats explicit
deny entries as reviewed (no warning).
- lib/utils/reify-finish.js: runs the walker and passes results to
reify-output as an extras payload.
- lib/utils/reify-output.js: prints the grouped summary after the
funding and audit messages. JSON output puts the same data on
summary.unreviewedScripts.
Refs: npm/rfcs#868
Both share an implementation in lib/utils/allow-scripts-cmd.js; the
files in lib/commands/ are thin shims that set verb = 'approve' or
'deny'.
- npm approve-scripts <pkg>: writes 'pkg@version': true (pinned)
- npm approve-scripts --no-pin <pkg>: writes 'pkg': true (name-only)
- npm approve-scripts --all: approves every unreviewed install-script
package in the resolved actual tree
- npm approve-scripts --pending: read-only walk, lists unreviewed
packages without modifying package.json
- npm deny-scripts <pkg>: writes 'pkg': false. Always name-only,
regardless of --pin, per the RFC's asymmetric-pin rule.
- npm deny-scripts --all: denies every unreviewed install-script
package
The shared writer in lib/utils/allow-scripts-writer.js implements the
RFC's pin-mismatch table as pure functions:
applyApprovalForPackage(existing, nodes, { pin }) and
applyDenyForPackage(existing, nodes). Grouping by package matters
because a per-node API can't tell a stale pin from a newly installed
version; a per-package one can.
Two new flags registered in workspaces/config/lib/definitions
(--all reuses the existing global definition):
- --pending: read-only mode for approve-scripts
- --pin: control pin behaviour for approve-scripts (default true)
Includes docs/lib/content/commands/{npm-approve-scripts,npm-deny-scripts}.md.
Refs: npm/rfcs#868
The allowScripts policy must live at the project root. A non-root workspace declaring its own allowScripts field is almost always a mistake: that policy would be silently ignored at install time. reify-finish now walks the resolved actual tree after reify completes and emits one warning per non-root workspace whose package.json has an allowScripts field. Pure detection lives in lib/utils/warn-workspace-allow-scripts.js; the tree walk piggybacks on the inventory that's already loaded for the unreviewed-scripts summary. Refs: npm/rfcs#868
Both share an implementation in lib/utils/allow-scripts-cmd.js; the
files in lib/commands/ are thin shims that set verb = 'approve' or
'deny'.
- npm approve-scripts <pkg>: writes 'pkg@version': true (pinned)
- npm approve-scripts --no-pin <pkg>: writes 'pkg': true (name-only)
- npm approve-scripts --all: approves every unreviewed install-script
package in the resolved actual tree
- npm approve-scripts --pending: read-only walk, lists unreviewed
packages without modifying package.json
- npm deny-scripts <pkg>: writes 'pkg': false. Always name-only,
regardless of --pin, per the RFC's asymmetric-pin rule.
- npm deny-scripts --all: denies every unreviewed install-script
package
The shared writer in lib/utils/allow-scripts-writer.js implements the
RFC's pin-mismatch table as pure functions:
applyApprovalForPackage(existing, nodes, { pin }) and
applyDenyForPackage(existing, nodes). Grouping by package matters
because a per-node API can't tell a stale pin from a newly installed
version; a per-package one can.
Two new flags registered in workspaces/config/lib/definitions
(--all reuses the existing global definition):
- --pending: read-only mode for approve-scripts
- --pin: control pin behaviour for approve-scripts (default true)
Includes docs/lib/content/commands/{npm-approve-scripts,npm-deny-scripts}.md.
Refs: npm/rfcs#868
The allowScripts policy must live at the project root. A non-root workspace declaring its own allowScripts field is almost always a mistake: that policy would be silently ignored at install time. reify-finish now walks the resolved actual tree after reify completes and emits one warning per non-root workspace whose package.json has an allowScripts field. Pure detection lives in lib/utils/warn-workspace-allow-scripts.js; the tree walk piggybacks on the inventory that's already loaded for the unreviewed-scripts summary. Refs: npm/rfcs#868
A pure isScriptAllowed(node, policy) helper in
workspaces/arborist/lib/script-allowed.js. Used by the install-time
warning walker and by the approve-scripts / deny-scripts commands.
Matching rules follow the RFC:
- registry deps: name + optional semver (range or exact)
- git deps: canonical ssh-url match plus short-SHA prefix
- file / directory / remote tarball: exact resolved string match
- alias spec keys are ignored entirely; a user must address the real
package name, not the alias
- matching uses node.packageName, never node.name, so an alias
install cannot be approved by writing its alias name
Conflict resolution: any matching false wins over any matching true.
No match returns null (unreviewed).
Pure function, no I/O. 15 test cases cover alias safety and
omitLockfileRegistryResolved.
Refs: npm/rfcs#868
…tall scripts
The Phase 1 advisory warning. No scripts are blocked. After arb.reify()
completes, reify-finish walks the actual tree for dependencies whose
install-relevant lifecycle scripts are not yet covered by the
allowScripts policy. The result is appended to the install output as
one grouped block, not one log line per package.
- workspaces/arborist/lib/install-scripts.js: per-node helper that
returns the install-relevant lifecycle scripts. Covers preinstall,
install, postinstall, prepare (non-registry sources only), and the
synthetic 'node-gyp rebuild' detected by isNodeGypPackage from
@npmcli/node-gyp. The runtime fs check is needed because the
lockfile's hasInstallScript field misses packages whose only
install-time work is binding.gyp.
- lib/utils/check-allow-scripts.js: walks arb.actualTree.inventory
and filters to unreviewed nodes. Honours --ignore-scripts and
--dangerously-allow-all-scripts as full opt-outs. Treats explicit
deny entries as reviewed (no warning).
- lib/utils/reify-finish.js: runs the walker and passes results to
reify-output as an extras payload.
- lib/utils/reify-output.js: prints the grouped summary after the
funding and audit messages. JSON output puts the same data on
summary.unreviewedScripts.
Refs: npm/rfcs#868
Both share an implementation in lib/utils/allow-scripts-cmd.js; the
files in lib/commands/ are thin shims that set verb = 'approve' or
'deny'.
- npm approve-scripts <pkg>: writes 'pkg@version': true (pinned)
- npm approve-scripts --no-pin <pkg>: writes 'pkg': true (name-only)
- npm approve-scripts --all: approves every unreviewed install-script
package in the resolved actual tree
- npm approve-scripts --pending: read-only walk, lists unreviewed
packages without modifying package.json
- npm deny-scripts <pkg>: writes 'pkg': false. Always name-only,
regardless of --pin, per the RFC's asymmetric-pin rule.
- npm deny-scripts --all: denies every unreviewed install-script
package
The shared writer in lib/utils/allow-scripts-writer.js implements the
RFC's pin-mismatch table as pure functions:
applyApprovalForPackage(existing, nodes, { pin }) and
applyDenyForPackage(existing, nodes). Grouping by package matters
because a per-node API can't tell a stale pin from a newly installed
version; a per-package one can.
Two new flags registered in workspaces/config/lib/definitions
(--all reuses the existing global definition):
- --pending: read-only mode for approve-scripts
- --pin: control pin behaviour for approve-scripts (default true)
Includes docs/lib/content/commands/{npm-approve-scripts,npm-deny-scripts}.md.
Refs: npm/rfcs#868
The allowScripts policy must live at the project root. A non-root workspace declaring its own allowScripts field is almost always a mistake: that policy would be silently ignored at install time. reify-finish now walks the resolved actual tree after reify completes and emits one warning per non-root workspace whose package.json has an allowScripts field. Pure detection lives in lib/utils/warn-workspace-allow-scripts.js; the tree walk piggybacks on the inventory that's already loaded for the unreviewed-scripts summary. Refs: npm/rfcs#868
Both share an implementation in lib/utils/allow-scripts-cmd.js; the
files in lib/commands/ are thin shims that set verb = 'approve' or
'deny'.
- npm approve-scripts <pkg>: writes 'pkg@version': true (pinned)
- npm approve-scripts --no-pin <pkg>: writes 'pkg': true (name-only)
- npm approve-scripts --all: approves every unreviewed install-script
package in the resolved actual tree
- npm approve-scripts --pending: read-only walk, lists unreviewed
packages without modifying package.json
- npm deny-scripts <pkg>: writes 'pkg': false. Always name-only,
regardless of --pin, per the RFC's asymmetric-pin rule.
- npm deny-scripts --all: denies every unreviewed install-script
package
The shared writer in lib/utils/allow-scripts-writer.js implements the
RFC's pin-mismatch table as pure functions:
applyApprovalForPackage(existing, nodes, { pin }) and
applyDenyForPackage(existing, nodes). Grouping by package matters
because a per-node API can't tell a stale pin from a newly installed
version; a per-package one can.
Two new flags registered in workspaces/config/lib/definitions
(--all reuses the existing global definition):
- --pending: read-only mode for approve-scripts
- --pin: control pin behaviour for approve-scripts (default true)
Includes docs/lib/content/commands/{npm-approve-scripts,npm-deny-scripts}.md.
Refs: npm/rfcs#868
The allowScripts policy must live at the project root. A non-root workspace declaring its own allowScripts field is almost always a mistake: that policy would be silently ignored at install time. reify-finish now walks the resolved actual tree after reify completes and emits one warning per non-root workspace whose package.json has an allowScripts field. Pure detection lives in lib/utils/warn-workspace-allow-scripts.js; the tree walk piggybacks on the inventory that's already loaded for the unreviewed-scripts summary. Refs: npm/rfcs#868
|
Someone else can write the RFC for the output I mentioned above. I'm too occupied and won't have time for that until I finish my other project. I think the list of scripts can be available when install is combined with --dry-run (not sure if it exists for install) and --verbose. Like I said above, I don't believe allow scripts alone will protect users when scripts can be dynamic without changing the integrity hash. A human, or AI, review must be done and for this to happen it would be necessary to have a list of all scripts that would have run so that a manual review can be performed. |
|
Our friend Claude wrote this script to let us know all packages that would have run during installation. (needs review) #!/usr/bin/env bash
# install-script-audit.sh
# Lists every script that would have run during install, so each can be audited.
# Run your install with `--ignore-scripts` first, then run this.
set -euo pipefail
ROOT="${1:-.}"
cd "$ROOT"
[ -d node_modules ] || { echo "node_modules not found in $ROOT" >&2; exit 1; }
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
# 1. Declared lifecycle scripts in every package.json under node_modules.
find -L node_modules -name package.json -type f 2>/dev/null \
| while IFS= read -r pj; do
jq -r --arg path "$pj" '
select(.scripts != null) | . as $r |
["preinstall","install","postinstall","prepare"][] as $h |
select($r.scripts[$h] != null) |
[$r.name // "?", $r.version // "?", $h, $r.scripts[$h], $path] | @tsv
' "$pj" 2>/dev/null || true
done > "$TMP/declared.tsv"
# 2. Packages with binding.gyp -> implicit `node-gyp rebuild` on install.
find -L node_modules -name binding.gyp -type f 2>/dev/null \
| while IFS= read -r gyp; do
pj="$(dirname "$gyp")/package.json"
[ -f "$pj" ] || continue
jq -r --arg path "$gyp" '
[.name // "?", .version // "?", "binding.gyp",
"(implicit: node-gyp rebuild)", $path] | @tsv
' "$pj" 2>/dev/null || true
done > "$TMP/native.tsv"
# 3. Merge, dedupe by name@version + hook, sort.
cat "$TMP/declared.tsv" "$TMP/native.tsv" \
| awk -F'\t' 'NF>=4 && !seen[$1"@"$2"|"$3]++' \
| sort -t$'\t' -k1,1 -k2,2 -k3,3 \
> install-script-audit.tsv
# 4. Cross-check against package-lock.json if present.
if [ -f package-lock.json ]; then
jq -r '
.packages // {} | to_entries[] |
select(.value.hasInstallScript == true) | .key
' package-lock.json | sort -u > install-script-audit.lockfile.txt
fi
total=$(wc -l < install-script-audit.tsv | tr -d ' ')
pkgs=$(awk -F'\t' '{print $1"@"$2}' install-script-audit.tsv | sort -u | wc -l | tr -d ' ')
prepares=$(awk -F'\t' '$3=="prepare"' install-script-audit.tsv | wc -l | tr -d ' ')
echo "Wrote install-script-audit.tsv"
echo " $total entries across $pkgs unique packages"
[ "$prepares" -gt 0 ] && echo " $prepares 'prepare' entries — only run for git/file deps; skip for registry deps."
[ -f install-script-audit.lockfile.txt ] && {
echo " Cross-check: install-script-audit.lockfile.txt"
echo " Anything in the lockfile set not in the audit list is suspicious."
}
echo
echo "Columns: name version hook command path"
echo "View: column -t -s \$'\\t' install-script-audit.tsv | less -S"With this script, the process for a human or AI audit would be as follows npm install --ignore-scripts
chmod +x install-script-audit.sh
./install-script-audit.sh
column -t -s $'\t' install-script-audit.tsv | less -SWith this output people can manually, or using AI, review each entry to determine (open the file and read to determine its whole flow) if it has dangerous executables, like dynamic ones that wouldn't have been caught even if the lock integrity hash hasn't changed. I do believe NPM should do this analysis for us using LLM consensus before making packages visible. NPM can track false positives until the system becomes reliable. Maybe you guys already have access to Mythos. Cant it be used for this? |
|
@JamieMagee @leobalter this RFC can be used as a "beacon" to actually help attackers. This RFC turns a private security signal into a public one. Today, the list of packages whose install scripts a company permits lives where attackers can't see it: internal CI configs, private Dockerfiles, npmrc files in CI secrets. The lockfile tells attackers what a company uses. allowScripts tells them what will execute. Only one of those was previously available. Only one of them is what attackers need. A scraper can collect every public allowScripts field and produce a ranked target list: "compromise this package, land code execution on these companies." |
|
There's prior art in this repo somewhere. (Sorry, I'm on the phone right now and I was sent a link to it) Also check out: https://www.npmjs.com/package/@lavamoat/allow-scripts |
|
@naugtur so it is a common practice to share the trust boundaries publicly? It feels insecure to let attackers know their targets. Can someone explain why it is not insecure, please? I would not add this info in my package.json, only as CLI arguments and in memory only Thank you for sharing this lava moat tool. I never heard of it before. @bakkot disliked my comment, so I would like to hear counter arguments, please. |
|
The list of packages you allow to run scripts from is easy to derive from your already public dependencies with few false positives. The realistic goal for disabling scripts by default is to remove the attack surface for the most common way malware reaches people now. Having to compromise a specific package with a preexisting install script is already making your reach as an attacker orders of magnitude smaller. and if you add a specific version to that, the information becomes useless (assuming npm registry remains immutable) |
|
I run After this RFC, I add Isn't this helping an attacker to do his work faster (reconnaissance work already done for him)? If yes, how isn't this just making things worse? |
|
But in this context, M and N are both numbers of packages they'd have to replace existing versions of. Meanwhile there's F~=1000*M other packages in the dependency tree that they have to choose as targets to publish new versions of with install scripts now without scripts being disabled. If you focus on targeted attacks, there are a dozen ways to compromise a project with all scripts off if you assume they can replace a specific package they choose. This is one of the least likely scenarios. TLDR: If I can see your lockfile, I can determine the M packages with a lot of precision by choosing the dependencies that have an install script and won't work without it This conversation would be easier if I could wave my hands. Can we bring back the NPM RFC call? 😅 |
|
I don't see where my reasoning drifted from the RFC specs. Making the list of scripts my org needs to run publicly available, hardcoded in a package.json, is a security flaw because it reduces the number of dependencies the attacker will invest time on. Im defending that I would want to use |
Today, for the vast majority of people using npm, it’s already public. The list of packages your package will run scripts in is all of the scripts in package.json. In that sense, this PR doesn’t make anything worse, and if you enable this feature by default (so you have to whitelist pakckages) it makes things vastly better for the majority of people. Even if you are publicly alllowing, say, express and lodash to run scripts, this hugely reduces the attack surface considering most large projects inevitably have some dependency in their tree with a small number of downloads a week and an overworked maintainer who is happy to take submissions from anywhere. Most npm packages (including lodash and express) have no reason to run scripts. A very large number of projects will just disable all install scripts from running, except perhaps their own. I don’t think anything here is stopping you from continuing to run some alternative solution to this if you’re the paranoid type who’d rather keep this private. |
|
The information we're talking about keeping private is easily guessable from a lockfile or package.json. I don't know how to say it more clearly. |
|
@naugtur in my example there is no way to guess which scripts I would run. You can only guess that I would run all or none (N or 0), and then as an attacked you would have to spend time trying to inject attacks in N targets to try to catch me. After the RFC, my org decided to stop using Again, I'm not against using allowScripts, just that it shouldn't read from a file that is usually publicly available. |
|
I created an analogy to help you understand my reasoning. Imagine an attacker wants to breach a company's production environment. The company has 1,000 employees, but only 5 have production deployment access. If the attacker doesn't know who those 5 are, they are forced to cast a wide net. They have to phish broadly, which is slow, noisy, expensive, and highly likely to trigger security alarms. Now, imagine the company publishes a directory on their public website: "These are the 5 specific engineers with production access." The attacker stops guessing immediately. They skip 99% of their reconnaissance, pick the most vulnerable engineer on that list, and execute a precise, highly targeted attack. This is exactly what happens if we culturally accept putting an allowScripts field directly in package.json. To be clear, the mechanism of allowing specific scripts to bypass --ignore-scripts is not the problem—security-conscious organizations absolutely need that capability to function. The problem is where that information lives. Currently, the list of trusted packages that are permitted to execute code at install time lives in an opaque layer: private CI configurations, or secure internal documentation. Moving this list to package.json takes a sensitive security configuration and places it in a file that is fundamentally designed to be public, shared, and published. By normalizing this in package.json, we are handing attackers a public attack roadmap. We are explicitly telling them: "If you want to achieve arbitrary code execution on our developers' machines or in our CI pipeline, don't bother guessing—just compromise this specific upstream dependency." It drastically reduces the friction of a supply chain attack by doing the reconnaissance work for the attacker. Security exceptions belong in private environment configurations, not public metadata. |
|
What packages they actually use are already in package.json, and which of these packages need to run scripts is already public knowledge, no? |
True, but what is not public knowledge is which ones I actually execute. If all were to be executed why would "allowScripts" be needed? Without this information attackers have to try injecting attacks in all. Check if the analogy above makes sense. |
|
I think this is true in the abstract, but practically speaking if I npm install a package and it works fine, I’m not going to allow it to run scripts, and if I npm install a package and it doesn’t work because it uses a post-install script to build some native library, then I’m going to have to add it to my allowScripts. It makes little sense to install a package that requires script access to function and then deny it the permission to run scripts, and it’s even more strange to install a package that doesn’t need to run scripts and grant it that permission. So figuring out which scripts have been granted access seems like a fairly trivial exercise. I also very much suspect of you add this feature, especially if there’s some scary language when you “npm allow scripts …” (Are you SURE? This could steal all your moneys!) then scripts that require access to run post-install scripts will suddenly decline in popularity, which is also a good thing from a security perspective. (Also, worth mentioning that whether or not a package can run scripts or not at install time is fine, but once you import the package in node.js, it can run whatever commands it likes and open whatever network connections it wants. This is only part of the puzzle.) |
@sheplu The default for Keeping this RFC scoped to install scripts. @AllanOricil Pinning entries to a version ( And on the bigger framing: most of the recent npm supply-chain incidents (Shai-Hulud, Axios) didn't come from an attacker targeting some specific company's allowlist. They came from a fresh malicious version of some dep in the tree running its install script in passing. That's the surface this RFC is aimed at, and whether your allowlist is public or private doesn't really move the needle there. |
The pinning argument doesn't fully hold here. I mentioned a dynamic-fetch case earlier in the thread — pinning a version guarantees the same package bytes, but it doesn't guarantee what those bytes go fetch at install time. A postinstall that does something like https.get("https://example.com/tool/latest") produces a different executed payload depending on what the URL serves, even with the tarball pinned and the integrity hash valid. On the broader point: putting the allowlist in package.json does help attackers narrow their target set. Today, knowing which packages a security-conscious org permits to run scripts requires reconnaissance work. With a public allowlist, that work is largely done for them. It introduces a reconnaissance surface that doesn't exist today, separate from whether the RFC succeeds at its stated goal of blocking typosquats. For example, Pinning to pkg@1.2.3 guarantees you get the same bytes. It doesn't guarantee what those bytes go fetch at install time. If pkg@1.2.3's postinstall does https.get("https://example.com/tool/latest"), the tarball is immutable but the executed payload is whatever the URL serves. A leaked allowlist tells attackers which orgs trust which dynamic-fetch scripts. They don't need to compromise the npm package, publish a new version, or phish a maintainer — just control the URL the script fetches from. Originally-author-malicious is the most realistic path: publish a "clean" version that does dynamic fetching, wait for orgs to allowlist it, flip the URL content later. No npm-side metric flags this — provenance valid, integrity hash matches, no new version published. So the pinning argument only protects against attacks that modify the package bytes. The public allowlist enables a different attack class that pinning explicitly doesn't cover. With that said, NPM must not normalize declaring allow scripts in a public facing file. It must not even be possible to do it. |
You're right on the technical point. I shouldn't have framed pinning + lockfile
First sentence yes, second sentence no. The dynamic-fetch attack works whether the allowlist is public or private. A malicious maintainer doesn't need anyone's
Forbidding the public layer has real costs. Open-source projects legitimately want to share their install-script trust in-repo, the same way they share
The dynamic-fetch class deserves a real defense, but the right layer for it isn't where the allowlist is stored. It's at install-time network egress: hash-pinned downloads, |
|
I asked Gemini Pro to synthesize my reasoning into a final comment. I’ve been following the discussions around introducing an allowScripts parameter to package.json to let specific packages bypass the --ignore-scripts flag during npm install. While I completely agree that we need a built-in mechanism to selectively allow lifecycle scripts, I strongly believe that culturally accepting its placement inside package.json is a massive security mistake. I didn’t read the official MITRE documentation directly before this, so I ran my logic through an AI chat to stress-test my theory. It pulled an exact mapping to a real-world security framework that I think everyone should look at to see if it makes sense. The Analogy: Giving Away the Keys Now, imagine the company publishes a directory on their public website: "These are the 5 specific engineers with production access." The attacker stops guessing immediately, picks a target on that list, and skips 99% of their reconnaissance. This is exactly what happens if we put an allowScripts field directly in package.json. The Counter-Argument: "But everyone knows which packages run scripts!" The immediate pushback I got was: "What packages a project uses are already public in package.json, and which of those packages contain install scripts is already public knowledge." True, but what is not public knowledge is your internal security policy. An attacker might see 15 dependencies that want to run scripts, but they have no idea if your CI blocks them all by default. Without knowing what you actually whitelist, an attacker has to gamble on which package to compromise. The moment you add allowScripts: ["package-a"] to a public package.json, you solve the attacker's dilemma. You explicitly tell them: "Don't bother trying to inject malware into the other 14 packages; we block them. Focus 100% of your energy on Package A, because we have explicitly carved out an active hole in our firewall for it." The MITRE ATT&CK Mapping (What the AI Found) Under T1593.003, adversaries actively scrape open-source repositories and public registries for target intelligence to optimize their supply chain attacks. By putting allowScripts in package.json, we are effectively performing the attacker's passive reconnaissance for them. It allows a threat actor to quietly identify vulnerabilities without ever touching private infrastructure or triggering a single internal security log. Conclusion Take a look at the MITRE ATT&CK T1593 concept alongside this RFC. Does the logic hold up to you? To me, keeping these exceptions in private configurations (like .npmrc or environment flags) isn't just security by obscurity—it’s denying a targeted attacker a free roadmap. |
|
The allow list would only go in applications, not packages, so it'd never be public. |
I believe that people will start publishing apps with package.json with "allowScripts" to public github repositories because, as seen here, most don't seen to realize that they will be given away relevant information for an attack. |
Summary
Block dependency install scripts (
preinstall,install,postinstall, and auto-detectednode-gypbuilds) by default duringnpm install. Projects opt in to running scripts for specific dependencies via a newallowScriptsfield inpackage.json. Two new CLI commands,npm approve-scriptsandnpm deny-scripts, help users build and maintain the allowlist.npm is the only remaining major package manager that runs dependency install scripts by default. pnpm v10+, Yarn Berry, Bun, and Deno all block them.
Why now
Recent attacks:
postinstall.postinstallhook, deploying a cross-platform RAT. The malicious package was never imported in Axios source code.Install scripts run automatically the moment a package lands in the dependency tree. They don't require any
require()orimportfrom the application. A typo, a transitive dep change in a lockfile a reviewer didn't read, or a maintainer compromise becomes immediate code execution.Relationship to other RFCs