Registry & Package Manager Hardening

Permalink to "Registry & Package Manager Hardening"

This page is part of Supply Chain Auditing & Dependency Verification, and it deals with the earliest link in the chain: the moment a package manager contacts a registry, downloads a tarball, unpacks it into node_modules and — unless you have stopped it — runs code from that tarball as your user, on your laptop or on a build agent that holds cloud credentials. Everything else on this site assumes the artifact you shipped is the artifact you meant to ship. That assumption is only true if the install step was trustworthy.

The install path is unusually attractive to an attacker because it is fast, automated and largely unwatched. A developer runs npm install dozens of times a day and reads none of the output. A CI job installs several hundred transitive packages in twenty seconds inside a container that also holds a deploy key, a registry token and a cloud role. Nothing about that sequence involves a browser, a Content Security Policy or an integrity attribute, so the controls this site spends most of its time on simply are not in play yet. This page covers the controls that are: lifecycle script suppression, configuration precedence and secret placement, registry routing and allow-listing, authentication hygiene, package manager pinning, and the resolution rules that make dependency confusion possible.

Prerequisites

Permalink to "Prerequisites"

Conceptual Foundation: What Actually Executes During an Install

Permalink to "Conceptual Foundation: What Actually Executes During an Install"

A package install is not a file copy. For every package in the resolved tree, npm may run three lifecycle scripts declared in that package’s own package.json: preinstall before the package is unpacked into place, install after unpacking, and postinstall immediately after that. A fourth, prepare, runs when a dependency is installed from a git URL — because a git dependency has no published tarball and must be built from source — and also runs in your own project when you type npm install with no arguments. These scripts are ordinary shell commands. They inherit the environment of the process that started the install, they run with your user’s permissions, and their output is hidden by default.

The consequence is blunt: adding one line to package.json gives any package in your tree, at any depth, arbitrary code execution on every machine that installs it. It does not matter whether your application ever imports that package. A build-only dependency, a transitive dependency of a linter, a package pulled in by a package pulled in by a test runner — each gets the same execution opportunity. Every environment variable exported into that shell is readable: registry tokens, cloud credentials, signing keys, the GITHUB_TOKEN of the running job. Self-replicating npm worms have used exactly this path, reading a registry token out of the environment during postinstall and using it to publish trojanised versions of every package the compromised account could write to.

The W3C Subresource Integrity specification has nothing to say about any of this, and that is the point worth internalising. SRI defines how a browser validates the bytes of a fetched subresource against a digest in the markup. It begins at request time, in a user agent, against an asset you have already built and deployed. The install step happens hours earlier, on a different machine, with no user agent involved.

Install time versus delivery time The upper row shows a registry sending a tarball to a package manager, which unpacks it and runs lifecycle scripts on a developer or CI host that holds tokens and keys. A dashed line separates that from the lower row, where a built bundle is published to a CDN and a browser verifies it with an integrity attribute. Where install-time code runs, and where SRI begins Install time — code executes on your machines npm registry tarball + metadata fetch package manager resolve and unpack unpack lifecycle scripts preinstall · install postinstall · prepare run developer / CI host tokens · keys · env Delivery time — SRI and CSP apply from here on built bundle hash computed publish CDN or origin serves the bytes request browser integrity verified Hardening the install protects the left half; SRI protects the right.

Step 1 — Block Lifecycle Scripts by Default

Permalink to "Step 1 — Block Lifecycle Scripts by Default"

The single highest-value change is to stop dependency lifecycle scripts from running automatically. In npm this is one configuration key:

# Write ignore-scripts=true into the project's own .npmrc
npm config set ignore-scripts true --location=project

# Verify the resolved value and where it came from
npm config get ignore-scripts
npm config ls -l | grep -i ignore-scripts

In CI, set it on the command rather than relying on a file that a pull request could edit:

npm ci --ignore-scripts

pnpm version 10 and later already refuses to run dependency build scripts unless you name the packages explicitly, which is the model to copy. The allow-list lives in package.json:

{
  "name": "acme-web",
  "packageManager": "[email protected]",
  "pnpm": {
    "onlyBuiltDependencies": ["esbuild", "sharp"]
  }
}

Yarn Berry has an equivalent switch in .yarnrc.yml:

enableScripts: false
nodeLinker: node-modules

npm itself has no per-dependency allow-list, which is the one real gap in this control. The workaround is a two-phase install: block everything, then rebuild the small number of packages that genuinely need a native build step.

# Phase 1 — nothing from the dependency tree executes
npm ci --ignore-scripts

# Phase 2 — explicitly named packages get their build lifecycle, with output shown
npm rebuild --foreground-scripts esbuild sharp

Verification signal. Before you make the change, find out which packages would have executed. npm query runs a CSS-like selector over the installed tree:

npm query ":attr(scripts, [postinstall])" | jq -r '.[].name' | sort -u

On a typical frontend repository this returns somewhere between two and fifteen names out of several hundred packages. That list is your allow-list candidate set, and it belongs in code review: a pull request that adds a name to it is a pull request that grants code execution rights on every machine in the team. The guide Disabling npm Install Scripts covers the rollout sequence for an existing repository, including how to find the packages that silently depended on a script you have now removed.

Step 2 — Fix the Configuration Precedence Chain

Permalink to "Step 2 — Fix the Configuration Precedence Chain"

ignore-scripts=true is worth nothing if a later configuration layer overrides it, and npm resolves configuration from six sources in a fixed order. Command-line flags win first, then environment variables named NPM_CONFIG_* (or lowercase npm_config_*), then the project’s ./.npmrc, then the user’s ~/.npmrc, then the global $PREFIX/etc/npmrc, and finally the builtin npmrc shipped inside npm itself. The first layer that sets a key decides its value. An environment variable therefore beats your committed project file, which is exactly how a compromised CI job or a careless export can silently re-enable scripts.

npm configuration precedence Six stacked bars numbered one to six, from command-line flags at the top through environment variables, project npmrc, user npmrc, global npmrc and the builtin npmrc at the bottom, each annotated with the secret-handling risk of that layer. npm config precedence — first match wins highest precedence at the top where secrets end up 1 command-line flags — npm ci --ignore-scripts explicit, not persisted 2 environment — NPM_CONFIG_* / npm_config_* CI injects tokens here 3 project — ./.npmrc, committed to the repo never store a token 4 user — ~/.npmrc real auth tokens live here 5 global — $PREFIX/etc/npmrc shared on build agents 6 builtin npmrc, shipped inside npm registry default only

Two rules follow from that ordering. First, put the security-relevant defaults in the project file so they travel with the repository and show up in review, and re-assert the critical ones as command-line flags in CI so no environment variable can quietly win. Second, never let a credential reach the project file. Authentication keys in npm are addressed by registry URI without the protocol, and npm expands ${VAR} references from the environment when it reads an npmrc, so the committed file can reference a secret it does not contain:

# ./.npmrc — safe to commit
engine-strict=true
ignore-scripts=true
save-exact=true
audit-level=high
provenance=true

registry=https://npm.acme.dev/
@acme:registry=https://npm.acme.dev/
//npm.acme.dev/:_authToken=${NPM_TOKEN}

The literal token then only ever exists in the CI secret store and in the running process environment. In container builds, keep it out of the image layers entirely with a build secret mount rather than a copied file:

# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json .npmrc ./
RUN --mount=type=secret,id=npm_token,env=NPM_TOKEN \
    npm ci --ignore-scripts
COPY . .
RUN npm run build

Verification signal. npm config ls -l prints every resolved key together with the file it came from; run it inside the CI job, not on a laptop, because that is where the layering actually differs. Then confirm no credential has ever been committed: git log -p --all -- .npmrc should show a file that never contained anything but a ${...} placeholder. If it did once, the token is public history and must be revoked, not rewritten.

Step 3 — Make the Registry a Chokepoint

Permalink to "Step 3 — Make the Registry a Chokepoint"

By default every package manager resolves everything from the public registry, over the open internet, from whichever machine happens to run the install. That gives you no allow-list, no audit trail of first use, and no protection against a package being changed or removed upstream. A private proxy in front of the public registry fixes all three at once, and it is a single configuration line on the client side.

# ./.npmrc — everything, public and private, resolves through one host
registry=https://npm.acme.dev/
@acme:registry=https://npm.acme.dev/

Scoped registry keys take precedence over the default registry for packages in that scope, which is what lets you split traffic. The important part is the server-side policy. A Verdaccio configuration expresses the two properties that matter — internal names never fall through to the public registry, and everything else is proxied and cached:

uplinks:
  npmjs:
    url: https://registry.npmjs.org/
    maxage: 30m
    cache: true

packages:
  '@acme/*':
    access: $authenticated
    publish: $authenticated
    # deliberately no `proxy:` key — an unknown @acme package is a 404, never an upstream lookup
  '**':
    access: $authenticated
    publish: $authenticated
    proxy: npmjs

Three properties are worth calling out. Allow-listing is the ability to refuse a package name outright at the proxy, which is how you enforce a review step before a new dependency enters the organisation. Immutable caching means that once the proxy has fetched [email protected] it keeps those exact bytes; a later upstream change or unpublish cannot alter what your builds install, and your CI keeps working when the public registry has an outage. Audit logging gives you the first-fetch timestamp for every package name, which is the fastest signal available when an advisory lands and you need to know whether you were ever exposed.

Immutability is enforced at two levels, and it is worth understanding both. The public registry itself refuses to accept a re-publish of an existing version, and permits unpublishing only within a 72-hour window under narrow conditions. Your lockfile then records a sha512 digest for every resolved tarball, so a substituted byte is caught by the client even if the server misbehaves — that failure surfaces as EINTEGRITY, described in the troubleshooting section below. The proxy sits between them and makes the guarantee local. The full deployment, including storage sizing, retention and the authentication modes, is covered in the private registry proxy guide linked from the prerequisites above.

Verification signal. After switching, a clean install should show every tarball coming from your host:

rm -rf node_modules
npm ci --ignore-scripts --loglevel=http 2>&1 | grep -oE 'https://[^/]+' | sort | uniq -c
#     412 https://npm.acme.dev

Any line pointing at registry.npmjs.org means a configuration layer is being bypassed — usually a stray ~/.npmrc, a workspace package with its own .npmrc, or a lockfile whose resolved URLs still carry the old host.

Step 4 — Harden Authentication and Publishing

Permalink to "Step 4 — Harden Authentication and Publishing"

Registry credentials are the highest-value secret in the pipeline, because a stolen publish token converts one compromised machine into a compromise of every consumer of your packages. Three changes, in order of impact.

Use granular access tokens, not classic ones. A classic automation token carries the full rights of the account that created it, never expires unless revoked, and deliberately bypasses the two-factor prompt so it can run unattended. A granular access token can be limited to specific packages or a single scope, granted read-only or read-and-write rights, restricted to an IP range, and given a mandatory expiry. Read-only is the correct level for a CI job that only installs, and it is the level most CI jobs are wrongly given publish rights for.

Require two-factor authentication for publishing. Set the organisation-wide requirement so that every human publish and every write to package settings needs a second factor. Interactively, npm prompts for the one-time password; scripted publishes that hit the requirement fail with an EOTP code rather than succeeding silently.

Replace long-lived publish tokens with trusted publishing. On supported CI providers, npm can authenticate a publish using a short-lived OIDC identity token minted by the CI platform for that specific workflow run, instead of a stored registry token. Nothing long-lived exists to steal, and the registry can verify which repository and workflow performed the publish. The workflow needs id-token: write, a recent npm CLI (11.5.1 or later), and no NODE_AUTH_TOKEN at all:

# .github/workflows/publish.yml
name: Publish
on:
  release:
    types: [published]

permissions:
  contents: read
  id-token: write        # required for OIDC trusted publishing and provenance

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          registry-url: 'https://registry.npmjs.org'

      - run: npm ci --ignore-scripts
      - run: npm run build
      - run: npm test

      # No NODE_AUTH_TOKEN: the OIDC identity token authenticates the publish
      - run: npm publish --access public

The same OIDC identity is what lets the registry attach a provenance attestation to the published tarball, tying it to the commit and workflow that produced it — the mechanics are covered in Publishing npm Packages with Provenance. On the consuming side, the registry’s signatures and any available attestations can be checked in one command:

npm audit signatures
# audited 412 packages in 6s
# 412 packages have verified registry signatures

Step 5 — Pin the Package Manager Itself

Permalink to "Step 5 — Pin the Package Manager Itself"

Every control above is enforced by a binary, and on most teams nobody has agreed which binary that is. One developer runs npm 10, another npm 11, CI runs whatever the base image ships, and a contractor uses pnpm because it is faster. The differences are not cosmetic: they change lockfile format, hoisting layout, whether lifecycle scripts run by default, and how configuration is read. A control that only exists in one of those tools is not a control.

The packageManager field in package.json declares the answer, and Corepack enforces it by downloading the named version on demand and verifying it before use:

{
  "name": "acme-web",
  "private": true,
  "packageManager": "[email protected]+sha512.c753b6c3ad7afa13af388fa6d808035a008e30ea9993f58c6663e2bc5ff21679aa834db094987129aa4d488b86df57f7b634981b2f827cdcacc698cc0cfb88afa",
  "engines": {
    "node": ">=22.0.0"
  }
}

The optional +sha512-... suffix is a hash of the package manager tarball itself, so a tampered download is rejected rather than executed. Enable Corepack in CI before the install step:

corepack enable
corepack install          # materialise the version named in package.json
pnpm install --frozen-lockfile --ignore-scripts

Corepack shipped inside Node.js distributions for several major versions, but its bundling status has changed in recent Node releases, so treat it as a tool you install explicitly rather than one you assume is present — check corepack --version in CI and install it if the command is missing. Pair the field with engine-strict=true so a mismatched Node version fails the install rather than producing a subtly different tree. Enforcing Package Manager Versions with Corepack covers the migration path for repositories where developers are currently on mixed versions.

Step 6 — Close the Dependency Confusion Gap

Permalink to "Step 6 — Close the Dependency Confusion Gap"

Dependency confusion is the failure mode that survives all of the above if you get one detail wrong. It works like this: your internal package @acme/ui is published only to your private registry, but a resolution request for it reaches the public registry — because a scoped registry key was missing, because a workspace has its own .npmrc, or because the proxy is configured to fall through to the upstream for names it does not recognise. An attacker who has guessed the name has already published @acme/ui publicly at version 99.0.0, and the resolver takes the higher version.

The defence is layered, and no single layer is sufficient. Publish internal packages under a scope you control and register that scope publicly so nobody else can claim it. Map the scope to the internal registry in the committed project file. Configure the proxy so that internal name patterns have no upstream at all, which turns a misconfiguration into a loud E404 instead of a silent public fetch. And keep resolution deterministic by installing from the lockfile, so a new upstream version cannot be selected mid-build in the first place.

Resolution decision tree for an internal scoped package A request for the package at-acme slash ui is checked twice: first whether the scope is mapped to an internal registry, then whether the name is on the proxy allow-list. A no at the first check leads to dependency confusion, a no at the second leads to a loud E404, and two yes answers lead to the tarball being served from the mirror. Resolving @acme/ui — three possible outcomes install request @acme/ui lookup scope mapped to an internal registry? yes name on the proxy allow-list? yes served from the mirror no public registry answers dependency confusion no E404 from the proxy install fails loudly A loud failure is the safe outcome; a silent public fetch is not.

Verification signal. The cheapest test is to ask the public registry whether your internal names are visible there. Any answer other than a 404 for a name you believe is private deserves immediate investigation — either someone has published your name, or a package you thought was internal has been leaking:

for pkg in @acme/ui @acme/config @acme/telemetry; do
  code=$(curl -s -o /dev/null -w '%{http_code}' "https://registry.npmjs.org/${pkg/\//%2f}")
  echo "$pkg -> $code"
done
# @acme/ui -> 404
# @acme/config -> 404
# @acme/telemetry -> 200   <-- investigate now

Configuration Reference

Permalink to "Configuration Reference"
Setting Where it lives Valid values Security effect
ignore-scripts npmrc, CLI flag true, false (default false) Suppresses preinstall, install, postinstall and prepare for the whole tree; the primary install-time execution control
foreground-scripts npmrc, CLI flag true, false (default false) Prints script output instead of hiding it, so an allowed build step is auditable in CI logs
engine-strict npmrc true, false (default false) Turns an engines mismatch from a warning into a hard EBADENGINE failure, keeping builds on the reviewed runtime
audit-level npmrc, CLI flag info, low, moderate, high, critical Sets the severity at which npm audit exits non-zero; only high or critical make sensible merge blockers
save-exact npmrc, CLI flag true, false (default false) Writes exact versions instead of ^ ranges, removing the window in which a new upstream release enters unreviewed
registry npmrc, env any HTTPS URL The default resolution host; point it at a proxy to gain allow-listing, caching and an audit trail
@scope:registry npmrc, env any HTTPS URL Per-scope override that beats registry; the control that prevents an internal scope resolving publicly
//host/:_authToken user npmrc, env token or ${VAR} Credential keyed by registry host; use the ${VAR} form so no literal token is ever committed
provenance npmrc, CLI flag true, false (default false) Makes npm publish request a provenance attestation; needs an OIDC-capable CI job
packageManager package.json name@version or name@version+sha512-… Names the exact tool allowed to perform the install; the hash suffix makes Corepack reject a tampered download
onlyBuiltDependencies package.json (pnpm) array of package names Per-package allow-list for build scripts; the model npm still lacks
enableScripts .yarnrc.yml true, false Yarn Berry’s equivalent of ignore-scripts

Integration with Adjacent Tooling

Permalink to "Integration with Adjacent Tooling"

Install hardening is a gate, and a gate is only useful if something watches it. Three integrations do most of the work.

Lockfile review. Every control here is expressed in files that a pull request can change: .npmrc, package.json, the lockfile’s resolved URLs. A branch that quietly repoints resolved at a different host, or adds a postinstall to a workspace package, should be caught by a reviewer or a bot rather than discovered afterwards — Detecting Lockfile Tampering in Pull Requests covers the diff patterns worth alerting on.

Update automation. A hardened registry path makes automated dependency updates safer, not riskier, because every proposed bump arrives as a reviewable pull request against a pinned baseline rather than as a range that silently resolves differently tomorrow. Automating Dependency Updates with Renovate shows how to point the bot at a private registry and to hold new releases for a cooling-off period before proposing them. Recent pnpm versions also expose a minimumReleaseAge setting that refuses to install versions published within the last N minutes, which blunts the fast-moving worm pattern; check your pnpm version before relying on it.

Vulnerability handling. audit-level is where install hardening meets triage. The threshold you set decides which advisories block a merge, and the process for everything below that threshold is described in Vulnerability Tracking & Triage. Run the audit after a script-free install so an advisory cannot be evaluated by code that has already executed.

Downstream, the hardened install produces the node_modules tree your bundler consumes, and the bundle it emits is what browsers will eventually verify. That handoff is the subject of Core SRI Fundamentals & Browser Security Boundaries; computing the digest for the emitted file is covered in Generating SRI Hashes with OpenSSL and shasum. The resulting tag is the last link in the chain this page started:

<script src="https://cdn.example.com/app.4f2c9b.js"
        integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
        crossorigin="anonymous"></script>

Troubleshooting

Permalink to "Troubleshooting"

npm error code EBADENGINE after enabling engine-strict

npm error code EBADENGINE
npm error engine Unsupported engine
npm error engine Not compatible with your version of node/npm: [email protected]
npm error notsup Required: {"node":">=22.0.0"}
npm error notsup Actual:   {"npm":"10.9.0","node":"v20.11.1"}

The runtime does not satisfy the engines range and engine-strict=true has turned the usual warning into a failure. This is the control working. Fix the environment — pin the Node version in CI and in your .nvmrc — rather than relaxing the constraint. If the failing constraint comes from a dependency rather than your own package, decide deliberately whether to widen your engines range or to drop the dependency; engine-strict applies to the whole tree.

ERR_PNPM_BAD_PM_VERSION on every pnpm command

 ERR_PNPM_BAD_PM_VERSION  This project is configured to use v10.4.1 of pnpm.
 Your current pnpm is v9.15.0

The packageManager field disagrees with the pnpm binary on PATH. Either let the version manager resolve it (corepack enable plus corepack install, or pnpm’s own manage-package-manager-versions setting, which lives in .npmrc for pnpm 9 and in pnpm-workspace.yaml for recent pnpm 10) or install the exact version. Do not delete the packageManager field to make the error go away — that removes the pinning control and reintroduces per-developer tool drift. Corepack reports the same class of mismatch as a usage error naming the packageManager field and the package.json that declared it.

npm error code E401 when installing from the private registry

npm error code E401
npm error Incorrect or missing password.
npm error If you were trying to login, change your password, create an
npm error authentication token or enable two-factor authentication then
npm error that means you likely typed your password in incorrectly.

The auth key did not match the host being contacted. The three common causes are a ${NPM_TOKEN} reference that expanded to an empty string because the secret was not exposed to that job, an auth line keyed to a different host than the registry value (the key must match the registry URI without the protocol, trailing slash included), and an expired granular token. Print npm config ls -l in the failing job — npm redacts the token value but shows which files contributed which keys.

npm error code E403 when publishing

npm error code E403
npm error 403 403 Forbidden - PUT https://registry.npmjs.org/@acme%2fui - You cannot publish over the previously published versions: 1.4.2.

Authentication succeeded and authorisation failed. Version 1.4.2 already exists and the registry refuses to overwrite it, which is the immutability guarantee doing its job — bump the version. A 403 on a first publish of a scoped package usually means the package is private by default and needs --access public, or that the token is read-only. If the failure instead reports code EOTP, the account requires a one-time password for publishing, which is the signal to move that workflow to trusted publishing rather than to weaken the two-factor requirement.

ENOENT at runtime after switching to --ignore-scripts

Error: ENOENT: no such file or directory, open '/app/node_modules/<pkg>/build/Release/binding.node'
    at Object.openSync (node:fs:596:3)

A package expected its own install or postinstall script to compile or download a binary, and that script never ran. spawn … ENOENT from a package that shells out to a downloaded executable is the same cause. Do not re-enable scripts globally. Add the package to the explicit rebuild list (npm rebuild --foreground-scripts <pkg>) or the pnpm onlyBuiltDependencies array, and note in review why that package needs execution rights.

npm error code EINTEGRITY on an otherwise unchanged lockfile

npm error code EINTEGRITY
npm error sha512-4a5Fh… integrity checksum failed when using sha512:
npm error wanted sha512-4a5Fh… but got sha512-9cB1x…. (1234 bytes)

The tarball delivered does not hash to the digest recorded in the lockfile. Benign causes are a corrupted local cache (npm cache verify, then npm cache clean --force) and a proxy that re-compresses tarballs instead of passing bytes through unchanged. The non-benign cause is content substitution somewhere between the registry and you. Treat it as an incident until you have reproduced a clean install from a different machine and network: never “fix” it by deleting the lockfile entry, which simply records whatever bytes arrived.

Security Boundary Note

Permalink to "Security Boundary Note"

Everything on this page hardens how packages reach your machine and what executes while they arrive. It does not verify what the maintainer actually published. That distinction matters, because the two are easy to conflate.

  • A hardened install of a malicious package is still a malicious package. ignore-scripts removes the automatic execution path; it does nothing once your bundler imports the module. Establishing that a tarball came from the source repository and build you expect is the job of Provenance Verification Workflows, and of npm audit signatures on the consuming side.
  • A proxy caches bytes, it does not read them. Allow-listing controls which names enter the organisation, not what those names contain. A compromised release of a package already on the allow-list passes straight through and is then cached immutably, which preserves the malicious bytes as faithfully as the good ones.
  • Lifecycle scripts are not the only execution path. Malicious code can live in the module body, in a build plugin, in a test fixture, or in an editor extension pulled from a different ecosystem entirely. Install-time controls narrow the window; they do not close it.
  • None of this is Subresource Integrity. SRI validates the bytes a browser fetches against a digest in your markup at request time. If a poisoned dependency is bundled into your application, the bundle is hashed after the poisoning, the digest matches perfectly, and every browser accepts it. SRI protects the delivery path from your build output to the user; registry hardening protects the path into your build. Neither one is a substitute for the other, which is why both belong in the same threat model.
  • A compromised developer machine defeats all of it. Configuration files, tokens and lockfiles all live on a filesystem the attacker now controls. These controls raise the cost of a remote, automated attack; they do not survive local compromise.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Does --ignore-scripts break packages that compile native code?

It breaks the ones that build or download a binary during install, because their install script never runs. The fix is not to re-enable scripts globally. Install with scripts off, then run npm rebuild for the specific packages that need a build step, so the set of packages allowed to execute code is an explicit, reviewable list in your CI configuration.

Is ignore-scripts enough to stop a malicious package?

No. It removes the automatic execution path at install time, which is where most opportunistic npm malware fires. A malicious package can still run code the moment your application or build tool imports it. Blocking scripts buys you a review window between install and first import; it is not a sandbox and it does not make an untrusted dependency safe.

Should CI use a classic automation token or a granular access token?

Prefer a granular access token, which can be limited to named packages or a single scope, given read-only or read-write rights, restricted by IP range, and forced to expire. Classic automation tokens carry the full rights of the account and bypass the two-factor prompt. Best of all, on supported CI providers use trusted publishing so no token exists to steal.

Does a registry proxy protect me if the upstream package itself is malicious?

Only partially. A proxy gives you a chokepoint for allow-listing, an audit log of every first fetch, and an immutable copy of the tarball you first accepted. It does not inspect the code. If a maintainer publishes a compromised version and your allow-list admits it, the proxy will faithfully cache and serve the malicious bytes.

What is the difference between the packageManager field and an engines constraint?

The engines field declares which Node.js and npm versions your package supports, and with engine-strict enabled npm fails the install with EBADENGINE when the runtime does not match. The packageManager field names the exact package manager and version that must perform the install, and Corepack downloads and verifies that binary. One constrains the runtime, the other constrains the tool.

How does registry hardening relate to Subresource Integrity in the browser?

They guard opposite ends of the same pipeline. Registry hardening controls which bytes enter your build and what code may execute while they arrive. Subresource Integrity controls which bytes a browser will accept from a CDN at request time. Neither substitutes for the other: a poisoned dependency ends up inside a bundle whose SRI hash is perfectly valid.

Permalink to "Related"

Articles in This Topic

Disabling npm Install Scripts
Configuring a Private npm Registry Proxy
Enforcing Package Manager Versions with Corepack
Back to Supply Chain Auditing & Dependency Verification