[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
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? |
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