diff --git a/tap-snapshots/test/lib/commands/config.js.test.cjs b/tap-snapshots/test/lib/commands/config.js.test.cjs index ca6bf25c81fb0..bbd099bf478ac 100644 --- a/tap-snapshots/test/lib/commands/config.js.test.cjs +++ b/tap-snapshots/test/lib/commands/config.js.test.cjs @@ -85,6 +85,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "init.license": "ISC", "init.module": "{CWD}/home/.npm-init.js", "init.version": "1.0.0", + "install-hooks-whitelist": null, "install-links": false, "install-strategy": "hoisted", "key": null, @@ -261,6 +262,7 @@ init.author.url = "" init.license = "ISC" init.module = "{CWD}/home/.npm-init.js" init.version = "1.0.0" +install-hooks-whitelist = null install-links = false install-strategy = "hoisted" json = false diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index ba3a537259c59..00b4278afafc8 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -905,6 +905,18 @@ number, if not already set in package.json. +#### \`install-hooks-whitelist\` + +* Default: null +* Type: null or String + +A comma-separated list of npm package names whose NPM install hooks are +allowed to run. Only hooks from packages listed here will be executed. +Package names should match exactly as published in the registry (including +scope if applicable, like @my-org/component). + + + #### \`install-links\` * Default: false @@ -2281,6 +2293,7 @@ Array [ "init.license", "init.module", "init.version", + "install-hooks-whitelist", "install-links", "install-strategy", "json", @@ -2440,6 +2453,7 @@ Array [ "include-staged", "include-workspace-root", "init-private", + "install-hooks-whitelist", "install-links", "install-strategy", "json", diff --git a/workspaces/arborist/lib/arborist/rebuild.js b/workspaces/arborist/lib/arborist/rebuild.js index eef557208208d..643d00604f50d 100644 --- a/workspaces/arborist/lib/arborist/rebuild.js +++ b/workspaces/arborist/lib/arborist/rebuild.js @@ -274,6 +274,19 @@ module.exports = cls => class Builder extends cls { } } + // Determine whether install hooks may run. + // Install hooks are allowed only for packages listed in the + // install-hook-whitelist setting + #isWhitelistedInstallHook (node, event) { + if (this.options.installHooksWhitelist?.length && event !== 'prepare') { + // check whether the package name is included in the defined whitelist + return this.options.installHooksWhitelist.includes(node.name?.toLowerCase()) + } + // either no whitelist exists, or the event is 'prepare' + // 'prepare' runs only on linked packages + return true + } + async #runScripts (event) { const queue = this.#queues[event] @@ -304,6 +317,11 @@ module.exports = cls => class Builder extends cls { return } + if (!this.#isWhitelistedInstallHook(node, event)) { + log.warn('hook execution', `skipping npm hook "${event}" for package "${node.name}". To enable hooks for this package, add "${node.name}" to your install-hooks-whitelist options`) + return + } + const timeEndLocation = time.start(`build:run:${event}:${location}`) log.info('run', pkg._id, event, location, pkg.scripts[event]) const env = { diff --git a/workspaces/arborist/test/arborist/rebuild.js b/workspaces/arborist/test/arborist/rebuild.js index 2ca190e680f25..07c5059eb1b51 100644 --- a/workspaces/arborist/test/arborist/rebuild.js +++ b/workspaces/arborist/test/arborist/rebuild.js @@ -847,3 +847,34 @@ t.test('do not run lifecycle scripts of linked deps twice', async t => { const arb = new Arborist({ path, ignoreScripts: true }) await arb.rebuild() }) + +t.test('only run install hooks for whitelisted packages', async t => { + function makePackage (name, dependencies) { + return { + name, + version: '1.0.0', + scripts: { + preinstall: `echo "Pre-install ${name}"`, + }, + dependencies, + } + } + const a = makePackage('@foo/a', {}) + const b = makePackage('@foo/b', {}) + const path = t.testdir({ + 'package.json': JSON.stringify(makePackage('my-project', { + '@foo/a': 'file:./node_modules/@foo/a', + '@foo/b': 'file:./node_modules/@foo/b', + })), + node_modules: { + '@foo': { + a: { 'package.json': JSON.stringify(a) }, + b: { 'package.json': JSON.stringify(b) }, + }, + }, + }) + const arb = new Arborist({ path, installHooksWhitelist: ['@foo/b'] }) + await arb.rebuild() + t.equal(arb.scriptsRun.size, 1) + t.equal(arb.scriptsRun.values().next().value.cmd, b.scripts.preinstall) +}) diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index 766182ad2bb1c..0f72ff2b93858 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -1061,6 +1061,23 @@ const definitions = { Alias for \`--init-version\` `, }), + 'install-hooks-whitelist': new Definition('install-hooks-whitelist', { + default: null, + type: [null, String], + description: ` + A comma-separated list of npm package names whose NPM install hooks are allowed to run. + Only hooks from packages listed here will be executed. Package names should + match exactly as published in the registry (including scope if applicable, like @my-org/component). + `, + flatten (key, obj, flatOptions) { + if (obj[key]) { + flatOptions.installHooksWhitelist = obj[key] + .split(',') + .map(v => v.trim().toLowerCase()) + .filter(Boolean) + } + }, + }), 'install-links': new Definition('install-links', { default: false, type: Boolean, diff --git a/workspaces/config/tap-snapshots/test/type-description.js.test.cjs b/workspaces/config/tap-snapshots/test/type-description.js.test.cjs index 7325654569b3d..2148359700104 100644 --- a/workspaces/config/tap-snapshots/test/type-description.js.test.cjs +++ b/workspaces/config/tap-snapshots/test/type-description.js.test.cjs @@ -266,6 +266,10 @@ Object { "init.version": Array [ "full valid SemVer string", ], + "install-hooks-whitelist": Array [ + null, + Function String(), + ], "install-links": Array [ "boolean value (true or false)", ], diff --git a/workspaces/config/test/definitions/definitions.js b/workspaces/config/test/definitions/definitions.js index 4e10b32bbdd8e..c31392fe54fcb 100644 --- a/workspaces/config/test/definitions/definitions.js +++ b/workspaces/config/test/definitions/definitions.js @@ -1013,6 +1013,14 @@ t.test('remap global-style', t => { t.end() }) +t.test('make array from install-hooks-whitelist', t => { + const obj = { 'install-hooks-whitelist': 'foo, bar, @foo/bar' } + const flat = {} + mockDefs()['install-hooks-whitelist'].flatten('install-hooks-whitelist', obj, flat) + t.strictSame(flat, { installHooksWhitelist: ['foo', 'bar', '@foo/bar'] }) + t.end() +}) + t.test('otp changes auth-type', t => { const obj = { 'auth-type': 'web', otp: 123456 } const flat = {}