Skip to content

[RFC] Make install scripts opt-in#868

Open
JamieMagee wants to merge 1 commit into
npm:mainfrom
JamieMagee:jamimagee/make-install-scripts-opt-in
Open

[RFC] Make install scripts opt-in#868
JamieMagee wants to merge 1 commit into
npm:mainfrom
JamieMagee:jamimagee/make-install-scripts-opt-in

Conversation

@JamieMagee
Copy link
Copy Markdown

Summary

Block dependency install scripts (preinstall, install, postinstall, and auto-detected node-gyp builds) by default during npm install. Projects opt in to running scripts for specific dependencies via a new allowScripts field in package.json. Two new CLI commands, npm approve-scripts and npm 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:

  • Shai-Hulud worm (September 2025): self-replicating postinstall payload compromised 500+ npm packages by stealing maintainer tokens and republishing infected versions.
  • chalk, debug, and 17 other packages (September 2025): phished maintainer account used to inject Web3 wallet-draining code into packages with over 2B combined weekly downloads. Delivered via postinstall.
  • Axios (March 2026): hijacked lead maintainer published versions with a phantom dependency that existed only to trigger its postinstall hook, 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() or import from 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

  • Supersedes RFC #488 (the 2021 ancestor, 369 👍, rejected as too disruptive at the time). The phased migration plan in this RFC addresses that concern directly.
  • Supersedes RFC #861 per author's note.

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.
@JamieMagee JamieMagee requested a review from a team as a code owner May 14, 2026 18:28
@JamieMagee JamieMagee changed the title Make install scripts opt-in [RFC] Make install scripts opt-in May 14, 2026
@brunoborges
Copy link
Copy Markdown

brunoborges commented May 14, 2026

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?

@leobalter
Copy link
Copy Markdown

@brunoborges The short answer is that a small but legitimate tail of packages still relies on install scripts (canvas, sharp, sqlite3, and similar native addons), and --ignore-scripts today is all-or-nothing, which is why it isn't a practical substitute.

The ecosystem has largely shifted to prebuilt binaries via optionalDependencies (esbuild, SWC, Sharp, Rollup, Biome, etc.), so the remaining surface is small - but it's non-zero, and removing the feature would break those packages with no clean migration path.

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.

@brunoborges
Copy link
Copy Markdown

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

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.

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.

@AllanOricil
Copy link
Copy Markdown

AllanOricil commented May 15, 2026

It would also be useful if --ignore-scripts combined with --verbose displayed the exact lifecycle scripts that would have been executed, including their resolved paths (real ones not symlinks) and content hashes.

With this output we can then edit the new --allowScripts flag with the exact output showed, including its sha, instead of just the package name

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

JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
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
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
…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
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
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
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
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
@JamieMagee
Copy link
Copy Markdown
Author

JamieMagee commented May 15, 2026

It would also be useful if --ignore-scripts combined with --verbose displayed the exact lifecycle scripts that would have been executed, including their resolved paths (real ones not symlinks) and content hashes.

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

With this output we can then edit the new --allowScripts flag with the exact output showed, including its sha, instead of just the package name

"allowScripts" : [
    "path/to/script.extension?sha256=foo"
]

The part I'm not sold on is hashing the script file itself. Lifecycle script values in package.json aren't file paths, they're shell command strings. A real postinstall looks like node scripts/setup.js && chmod +x ./bin/*. To meaningfully hash "what's about to run" you'd have to resolve every binary referenced on PATH, hash the transitive JS closure reachable from the entry script, and re-hash any time the Node.js or toolchain version changes. So every dependency update forces a full re-review of every script. In practice that's the dependency-approval equivalent of a default-deny TOFU prompt; people click through, and the signal disappears.

The current allowScripts proposal is still too permissive if approvals are scoped only to package names.

Agreed when it's name-only. The RFC's answer is that you can pin a version: "allowScripts": { "esbuild@0.20.0": true } only approves that exact version. Bumping to 0.20.1 puts esbuild back in npm approve-scripts --pending. The name-only form is there for convenience on packages whose install scripts you genuinely don't care to gate per-version.

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.

Pinned entries handle the version-to-version case, range keys like ^0.20.0 are rejected, and same-version byte changes are caught by the lockfile's SHA-512 integrity.

Because of that, approvals should be bound to the exact script file being executed, not just the package identity.

name@version + lockfile integrity already binds approvals to the exact bytes. Per-script hashing only helps if integrity is wrong. Do you have a concrete attack in mind that name-plus-version-plus-integrity misses?

@JamieMagee
Copy link
Copy Markdown
Author

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 allowScripts. The matcher, the approve-scripts / deny-scripts commands, policy layering, and the three new configs are all wired in. The arb.rebuild() gate is what Phase 2 will flip.

Reviews on either thread welcome.

@sheplu
Copy link
Copy Markdown

sheplu commented May 15, 2026

+1 on having something around that
do you want to include part of this RFC something similar for the allow-git config?

@AllanOricil
Copy link
Copy Markdown

AllanOricil commented May 15, 2026

@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:

  • the package version remains unchanged,
  • the lockfile integrity remains valid,
    but the actual executed payload can still change server-side at any time.

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.

@AllanOricil
Copy link
Copy Markdown

AllanOricil commented May 15, 2026

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?

JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
…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
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
…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
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
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
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
…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
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
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
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
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
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
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
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
…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
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
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
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
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
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
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
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
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
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
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
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
…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
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
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
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
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
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
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
JamieMagee added a commit to JamieMagee/cli-1 that referenced this pull request May 15, 2026
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
Copy link
Copy Markdown

AllanOricil commented May 15, 2026

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.

@AllanOricil
Copy link
Copy Markdown

AllanOricil commented May 15, 2026

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 -S

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

@AllanOricil
Copy link
Copy Markdown

AllanOricil commented May 16, 2026

@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.
After this RFC, the same list lives in a public field in package.json.

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."
The RFC effectively does that "reconnaissance work" (Google about it) for them.

@naugtur
Copy link
Copy Markdown

naugtur commented May 16, 2026

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
https://www.npmjs.com/package/can-i-ignore-scripts

@AllanOricil
Copy link
Copy Markdown

AllanOricil commented May 16, 2026

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

@naugtur
Copy link
Copy Markdown

naugtur commented May 16, 2026

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)

@AllanOricil
Copy link
Copy Markdown

AllanOricil commented May 16, 2026

I run npm install package --ignore-scripts and the scripts I run are listed in my env variables only and never shared. To target me, an attacker will have to go thorugh N possible dependencies, which can all be determined like mentioned by @naugtur

After this RFC, I add allowScripts in a publicly facing file, and start using npm install package because I know that only the trusted scripts will run. Now the attacker knows upfront that he doesn't need to look for N targets, only M (M <= N), which are the ones that are explicitly in the package.json.

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?

@naugtur
Copy link
Copy Markdown

naugtur commented May 16, 2026

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.
And with tooling that only runs scripts from precisely identified registry+package+version the information being public doesn't matter. You're going to run scripts from the packages you need to work or uninstall the packages when they don't work.

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

@AllanOricil
Copy link
Copy Markdown

AllanOricil commented May 16, 2026

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 allowScripts but only as CLI arguments, never stored in the repo.

@jwalton
Copy link
Copy Markdown

jwalton commented May 16, 2026

This RFC turns a private security signal into a public one.

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.

@naugtur
Copy link
Copy Markdown

naugtur commented May 16, 2026

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.

@AllanOricil
Copy link
Copy Markdown

@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 --ignore-scripts and started using allowScripts and made it available in package.json. Now attackers will have a much smaller list of targets to try injecting attacks. Isn't this reducing work for an attacker? I didn't see arguments that commiting allowScripts in package.json isn't doing "reconnaissance work". And if it is, then it is a security flaw by definition.

Again, I'm not against using allowScripts, just that it shouldn't read from a file that is usually publicly available.

@AllanOricil
Copy link
Copy Markdown

AllanOricil commented May 16, 2026

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.

@jwalton
Copy link
Copy Markdown

jwalton commented May 16, 2026

What packages they actually use are already in package.json, and which of these packages need to run scripts is already public knowledge, no?

@AllanOricil
Copy link
Copy Markdown

AllanOricil commented May 16, 2026

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.

@jwalton
Copy link
Copy Markdown

jwalton commented May 16, 2026

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

@JamieMagee
Copy link
Copy Markdown
Author

+1 on having something around that do you want to include part of this RFC something similar for the allow-git config?

@sheplu The default for --allow-git is already planned to go to none in npm 12.

Keeping this RFC scoped to install scripts.


@AllanOricil allowScripts doesn't have to live in package.json. The RFC also reads from --allow-scripts on the CLI and from .npmrc at the project, user, and global levels. Both take precedence over package.json, so if you'd rather not publish your allowlist, you can keep it in .npmrc or pass it via CLI in CI.

Pinning entries to a version (pkg@1.2.3) helps too: the registry packages are immutable, so a leaked allowlist isn't very useful. @naugtur already made that point.

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.

@AllanOricil
Copy link
Copy Markdown

AllanOricil commented May 17, 2026

Pinning entries to a version (pkg@1.2.3) helps too: the registry packages are immutable, so a leaked allowlist isn't very useful. @naugtur already made that point.

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.

@JamieMagee
Copy link
Copy Markdown
Author

Pinning to pkg@1.2.3 guarantees you get the same bytes. It doesn't guarantee what those bytes go fetch at install time.

You're right on the technical point. I shouldn't have framed pinning + lockfile integrity as a defense against leaked allowlists. They defend against new-version compromise (someone publishing a malicious 1.2.4). Your dynamic-fetch case (postinstall calls https.get, attacker controls what comes back) is a real attack and pinning doesn't touch it.

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.

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 package.json to know who trusts their script, they get the install traffic directly. For a third-party attacker compromising the fetched-from endpoint, the targeting list is also derivable from public lockfiles: install each dep with --ignore-scripts, observe which ones break without their install script, and that's the candidate allowlist. @jwalton made that
point above. A public allowlist makes derivation cheaper, not possible-vs-impossible.

NPM must not normalize declaring allow scripts in a public facing file. It must not even be possible to do it.

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 .eslintrc, .prettierrc, and .npmrc, so the project builds the same for every contributor. Pushing that use case to CONTRIBUTING.md instructions and ad-hoc bash is less auditable, not more. The RFC already supports .npmrc and CLI for anyone who wants the allowlist private. You can use that path on your own projects regardless of the default. The disagreement is whether to forbid the public option for everyone else.

Hey, look! These guys trusted this script which fetches executables from this hardcoded URL.

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, --offline in CI, sandbox network policy, or an RFC that puts integrity on fetched content the way the lockfile does for tarballs. Whether allowScripts is in package.json or .npmrc doesn't change any of that.

@AllanOricil
Copy link
Copy Markdown

AllanOricil commented May 17, 2026

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
​Imagine an attacker wants to breach a company's production environment. The company has 1,000 employees, but only 5 have 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, expensive, and noisy.

​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)
​When I asked the AI to look up how threat models view this, it pointed out that placing security bypass configurations in a public metadata file maps directly to MITRE ATT&CK Reconnaissance Tactic, Technique T1593.003: "Search Open Websites/Domains: Code Repositories."

​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
​The mechanism of allowScripts is great, but the location is the hazard. package.json defines what the software needs to run. Security exceptions define how a specific environment trusts its inputs.

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

@ljharb
Copy link
Copy Markdown
Contributor

ljharb commented May 17, 2026

The allow list would only go in applications, not packages, so it'd never be public.

@AllanOricil
Copy link
Copy Markdown

AllanOricil commented May 17, 2026

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants