Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/lib/content/commands/npm-approve-scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ it with `--global` (`-g`) fails with an `EGLOBAL` error, since global
installs (`npm install -g`) and one-off executions (`npm exec` / `npx`) have
no project `package.json` to write to. To allow install scripts in those
contexts, use the `--allow-scripts` flag at install time (for example
`npm install -g --allow-scripts=canvas,sharp`) or persist the setting with
`npm config set allow-scripts=canvas,sharp --location=user`.
`npm install -g canvas sharp --allow-scripts=canvas,sharp`) or persist the
setting with `npm config set allow-scripts=canvas,sharp --location=user`.

There are three modes:

Expand Down
4 changes: 2 additions & 2 deletions docs/lib/content/commands/npm-install-scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ it with `--global` (`-g`) fails with an `EGLOBAL` error, since global
installs (`npm install -g`) and one-off executions (`npm exec` / `npx`) have
no project `package.json` to write to. To allow install scripts in those
contexts, use the `--allow-scripts` flag at install time (for example
`npm install -g --allow-scripts=canvas,sharp`) or persist the setting with
`npm config set allow-scripts=canvas,sharp --location=user`.
`npm install -g canvas sharp --allow-scripts=canvas,sharp`) or persist the
setting with `npm config set allow-scripts=canvas,sharp --location=user`.

There are four subcommands:

Expand Down
10 changes: 6 additions & 4 deletions lib/commands/rebuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@ const { resolve } = require('node:path')
const { log, output } = require('proc-log')
const npa = require('npm-package-arg')
const semver = require('semver')
const { trustedDisplay } = require('@npmcli/arborist/lib/script-allowed.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
const checkAllowScripts = require('../utils/check-allow-scripts.js')
const resolveAllowScripts = require('../utils/resolve-allow-scripts.js')
const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js')
const { configSetAllowScripts } = require('../utils/allow-scripts-remediation.js')
const { configSetAllowScripts, policyKeyFor } = require('../utils/allow-scripts-remediation.js')

class Rebuild extends ArboristWorkspaceCmd {
static description = 'Rebuild a package'
Expand Down Expand Up @@ -77,9 +76,12 @@ class Rebuild extends ArboristWorkspaceCmd {
// `npm install-scripts` writes to a project package.json, which doesn't
// exist for global rebuilds. Point global users at `npm config set`,
// which writes the `allow-scripts` setting to their user .npmrc.
const names = unreviewed.map(({ node }) => trustedDisplay(node).name)
// Use the policy identity, not the display name: only registry deps
// are matched by name, so a name-based suggestion would leave git,
// file and tarball deps blocked.
const keys = unreviewed.map(({ node }) => policyKeyFor(node))
const remediation = this.npm.global
? `Run \`${configSetAllowScripts(names)}\` to allow their scripts.`
? `Run \`${configSetAllowScripts(keys)}\` to allow their scripts.`
: 'Run `npm install-scripts ls` to review.'
log.warn(
'rebuild',
Expand Down
206 changes: 203 additions & 3 deletions lib/utils/allow-scripts-remediation.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,209 @@
const {
getTrustedRegistryIdentity,
matches,
resolvedSourceSpecs,
trustedDisplay,
} = require('@npmcli/arborist/lib/script-allowed.js')

// Copy of isRegistryNode from arborist to check if a node is a registry dependency
// This is needed to determine if we can safely extract a package name
const isRegistryNode = (node) => {
// arborist Node objects have an isRegistryDependency getter
if (typeof node.isRegistryDependency === 'boolean') {
return node.isRegistryDependency
}
// Fall back to URL parsing for nodes without the getter (e.g., test fixtures)
if (!node.resolved) {
return !!node.version
}
// Registry tarballs live at `<host>/<pkg-name>/-/<pkg-name>-<version>.tgz`
// Strip query parameters and hash for matching
const urlWithoutQuery = node.resolved.split('?')[0].split('#')[0]
return /^https?:\/\/[^\/]+\/.+\/-\/[^\/]+-\d/.test(urlWithoutQuery)
}

// Extract package name from a registry URL, handling query parameters and auth
// This is similar to getTrustedRegistryIdentity but works even when the URL has query params
const extractPackageNameFromRegistryUrl = (url) => {
// Strip query parameters and hash
const cleanUrl = url.split('?')[0].split('#')[0]

const { URL } = require('node:url')
try {
const u = new URL(cleanUrl)
const parts = u.pathname.slice(1).split('/-/')
if (parts.length >= 2) {
// The part before /-/ is the package name (or scope/pkg-name)
return parts[0]
}
} catch {
// If URL parsing fails, try to extract from the path
}

// Fallback: try to extract from the path
const match = cleanUrl.match(/\/([^\/]+)\/-\/[^\/]+-\d/)
if (match) {
return match[1]
}

return null
}

// Policy keys come straight from resolved sources, which carry characters
// the shell acts on: `#` in a git committish starts a comment, and `&`,
// `?` or spaces in a tarball URL break the command apart. Quote whenever
// the value is not plainly safe, so the suggestion can be pasted as-is.
const SHELL_SAFE = /^[\w@,./:-]+$/

const shellQuote = (value) =>
SHELL_SAFE.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`
Comment thread
Ashish-CodeJourney marked this conversation as resolved.

// Characters that are unsafe in shell commands, even when quoted.
// `&` in cmd.exe runs the following text as a command.
// `|` pipes output.
// `>` and `<` redirect output/input.
// Query parameters (`?`) in URLs can contain these characters.
// We avoid suggesting copy-paste commands for URLs with these characters.
// Note: `#` is safe in cmd.exe (only starts comments in POSIX), so we allow it
// and rely on shellQuoting to handle it for POSIX shells.
const UNSAFE_FOR_SHELL = /[&|><\s?]/

// Check if a URL is a registry URL and extract the package name safely.
// For registry URLs with query parameters or auth, this extracts the
// package name without the sensitive parts.
const safeRegistryKey = (node) => {
// If we already have a trusted identity, use it
const trusted = getTrustedRegistryIdentity(node)
if (trusted && trusted.name) {
return trusted.name
}

// For registry URLs without trusted identity (e.g., due to query params),
// try to extract the package name from the URL
if (node.resolved && typeof node.resolved === 'string' && isRegistryNode(node)) {
const pkgName = extractPackageNameFromRegistryUrl(node.resolved)
if (pkgName) {
return pkgName
}
}

return null
}

// Check if a key is safe to include in a copy-paste shell command.
// Returns true if the key can be safely quoted and pasted into a shell.
// For non-registry deps with URLs containing unsafe characters, we return false
// to avoid suggesting commands that could execute arbitrary code when pasted.
const isSafeForShell = (key) => {
// Registry package names are always safe (alphanumeric, hyphens, underscores, dots)
// Git URLs, file paths, and tarball URLs may contain unsafe characters
if (typeof key !== 'string' || key === '') {
return false
}

// Check if it looks like a registry package name (no URL scheme)
if (!key.includes('://') && !key.includes('/') && !UNSAFE_FOR_SHELL.test(key)) {
return true
}

// For URLs, check if they contain characters that are unsafe even when quoted
// Also check for commas, which break the comma-separated list parsing
if (UNSAFE_FOR_SHELL.test(key) || key.includes(',')) {
return false
}

return true
}

// The blocked-scripts summary shows a human-readable name, but the
// allowScripts policy only matches registry deps by name. git, file, remote
// and tarball deps are matched by their resolved source, so a suggestion
// built from display names would leave their scripts blocked. Verify each
// candidate against the node with the real matcher, so the key we hand the
// user is one the policy will actually accept.
//
// For registry deps, we prefer the package name (without version or URL)
// to avoid exposing credentials or query parameters in the suggestion.
// For non-registry deps, we use the resolved source, but only if it's safe
// for shell use (no characters that could be exploited in shell commands).
const policyKeyFor = (node) => {
// Create a cleaned node for matching purposes (strip query params and hash)
// This allows getTrustedRegistryIdentity to work even when the original
// node.resolved contains query parameters with tokens
const cleanNode = { ...node }
if (node.resolved && typeof node.resolved === 'string') {
cleanNode.resolved = node.resolved.split('?')[0].split('#')[0]
}

// Try to get the trusted identity from the cleaned node
const trusted = getTrustedRegistryIdentity(cleanNode)
if (trusted && trusted.name) {
// For registry deps, verify that the package name matches
// We use the cleaned node for matching to avoid issues with query params
if (matches(cleanNode, trusted.name, false)) {
return trusted.name
}
}

// For registry URLs without trusted identity (e.g., due to unusual URL format),
// try to extract the package name from the URL
if (node.resolved && typeof node.resolved === 'string' && isRegistryNode(node)) {
const pkgName = extractPackageNameFromRegistryUrl(node.resolved)
if (pkgName) {
// Verify with the cleaned node
if (matches(cleanNode, pkgName, false)) {
return pkgName
}
}
}

// For non-registry deps or fallback, try the original candidates
const candidates = [node.resolved, ...resolvedSourceSpecs(node)]
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate !== '' && matches(node, candidate, false)) {
// Only return keys that are safe for shell use
if (isSafeForShell(candidate)) {
return candidate
}
// For registry nodes, also accept the candidate even if not shell-safe
// (we already tried to get a safe version above)
if (isRegistryNode(node)) {
// Strip query params from registry URLs to avoid exposing tokens
const cleaned = candidate.split('?')[0].split('#')[0]
const pkgName = extractPackageNameFromRegistryUrl(cleaned)
if (pkgName) {
return pkgName
}
return cleaned
}
// For non-registry nodes with unsafe URLs (commas, &, etc.),
// we cannot safely include them in a comma-separated list.
// Return null to indicate this.
return null
}
}

/* istanbul ignore next: defensive fallback for nodes without name */
return trustedDisplay(node).name || '<unknown>'
}

// Builds the `npm config set allow-scripts` command suggested to global
// users, who have no project package.json for `npm approve-scripts` to
// write to. `--location=user` keeps the setting in the user .npmrc instead
// of trying (and, for global installs, failing) to write it to the local
// project config.
const configSetAllowScripts = (names) =>
`npm config set allow-scripts=${names.join(',')} --location=user`
//
// Filter out null/undefined keys to avoid suggesting invalid entries
const configSetAllowScripts = (keys) =>
`npm config set allow-scripts=${shellQuote(keys.filter(k => k).join(','))} --location=user`

// Builds the `--allow-scripts=<keys>` flag global users add to the install
// they just ran. Deliberately not a whole command: npm.argv holds
// positionals only, so a reconstructed `npm install -g <specs>` would drop
// flags like --registry and retry against the default registry while
// allowing that package's scripts to run. A spec-less
// `npm install -g --allow-scripts=<keys>` is no better: it installs the
// current directory and fails with ENOENT reading package.json.
const allowScriptsFlag = (keys) => `--allow-scripts=${shellQuote(keys.filter(k => k).join(','))}`

module.exports = { configSetAllowScripts }
module.exports = { allowScriptsFlag, configSetAllowScripts, policyKeyFor }
22 changes: 13 additions & 9 deletions lib/utils/reify-output.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ const npmAuditReport = require('npm-audit-report')
const { readTree: getFundingInfo } = require('libnpmfund')
const { trustedDisplay } = require('@npmcli/arborist/lib/script-allowed.js')
const auditError = require('./audit-error.js')
const { configSetAllowScripts } = require('./allow-scripts-remediation.js')
const {
allowScriptsFlag,
configSetAllowScripts,
policyKeyFor,
} = require('./allow-scripts-remediation.js')

const reifyOutput = (npm, arb, extras = {}) => {
const { diff, actualTree } = arb
Expand Down Expand Up @@ -246,12 +250,12 @@ const unreviewedScriptsMessage = (npm, unreviewedScripts) => {
const header =
`${count} ${pkg} install scripts blocked because they are not covered by allowScripts:`

const names = []
const nodes = []
const lines = unreviewedScripts.map(({ node, scripts }) => {
const { name, version } = trustedDisplay(node)
/* istanbul ignore next: every test node has a name */
const display = name || '<unknown>'
names.push(display)
nodes.push(node)
const ver = version ? `@${version}` : ''
const events = Object.entries(scripts)
.map(([event, cmd]) => `${event}: ${cmd}`)
Expand All @@ -265,7 +269,7 @@ const unreviewedScriptsMessage = (npm, unreviewedScripts) => {
header,
...lines,
'',
...remediationLines(npm, names),
...remediationLines(npm, nodes),
].join('\n')
)
}
Expand All @@ -274,13 +278,13 @@ const unreviewedScriptsMessage = (npm, unreviewedScripts) => {
// exist for global installs (it throws EGLOBAL). For those, point users at
// the mechanism that does work globally: the `--allow-scripts` flag for a
// one-off, or `npm config set allow-scripts` to persist it.
const remediationLines = (npm, names) => {
const remediationLines = (npm, nodes) => {
if (npm.global) {
const list = names.join(',')
const keys = nodes.map(policyKeyFor)
return [
`Run \`npm install -g --allow-scripts=${list}\` to allow these scripts ` +
`once, or \`${configSetAllowScripts(names)}\` to allow them for ` +
'all global installs.',
`Re-run your install with \`${allowScriptsFlag(keys)}\` to allow these ` +
`scripts once, or run \`${configSetAllowScripts(keys)}\` to allow them ` +
'for all global installs.',
]
}
return [
Expand Down
14 changes: 7 additions & 7 deletions lib/utils/strict-allow-scripts-preflight.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
const checkAllowScripts = require('./check-allow-scripts.js')
const { trustedDisplay } = require('@npmcli/arborist/lib/script-allowed.js')
const { configSetAllowScripts } = require('./allow-scripts-remediation.js')
const { configSetAllowScripts, policyKeyFor } = require('./allow-scripts-remediation.js')

// Pre-flight check for `--strict-allow-scripts`. Call after arborist has
// been constructed but before `arb.reify()` runs, so that install scripts
Expand Down Expand Up @@ -51,13 +50,14 @@ const strictAllowScriptsPreflight = async ({ arb, npm, idealTreeOpts }) => {
// `npm install-scripts` writes to a project package.json, which doesn't
// exist for global installs. Point global users at the `--allow-scripts`
// flag and `npm config set allow-scripts`, which both work for global
// installs. Use the trusted display identity so the suggested `npm config
// set` value matches what the policy matches on, not the tarball's
// self-reported name.
const names = unreviewed.map(({ node }) => trustedDisplay(node).name)
// installs. Use the policy identity so the suggested `npm config set`
// value is a key the matcher accepts: only registry deps are matched by
// name, so a name-based suggestion would leave git, file and tarball deps
// blocked, and the name itself is the tarball's self-reported one.
const keys = unreviewed.map(({ node }) => policyKeyFor(node))
const remediation = npm.global
? 'Allow them with `--allow-scripts`, persist them with ' +
`\`${configSetAllowScripts(names)}\`, or bypass this ` +
`\`${configSetAllowScripts(keys)}\`, or bypass this ` +
'check with `--dangerously-allow-all-scripts`.'
: 'Approve them with `npm install-scripts approve`, deny them with ' +
'`npm install-scripts deny`, or bypass this check with ' +
Expand Down
Loading