Code that runs when you type npm install
Packages can run arbitrary scripts at install time — on developer laptops and CI runners full of tokens — before a single test runs.
- Weakness
- CWE-506 · Embedded malicious code · CICD-SEC-3
- Target
sec-lab/appdependency installs, locally and in CI
npm query to audit scriptsignore-scripts and allowlistslockfile disciplineisolating installs from secretsRun these techniques only against the lab you own. Using them on systems you don't have permission to test is illegal.
The threat
01How malicious packages get in
Three routes, all real: TYPOSQUATTING (a package named one letter away from a popular one), DEPENDENCY CONFUSION (a public package with the same name as your internal one, preferred by a misconfigured resolver), and COMPROMISED MAINTAINERS. The last is the scariest: in 2018
event-stream, in 2021ua-parser-js, and in September 2025 widely used packages includingchalkanddebugbriefly shipped malicious versions after maintainer accounts were phished. The same month, a self-spreading npm worm dubbed 'Shai-Hulud' stole tokens from developer machines and CI and republished infected packages.02Why install scripts are the sharp edge
npm runs a package's
preinstall,install, andpostinstallscripts automatically during installation, with the full permissions and environment of whoever ran the install. On a CI runner that environment often contains registry tokens and cloud credentials. Many legitimate packages use install scripts (to compile native code), which is why they're on by default, and why they're so attractive to attackers.
What's at risk
- Arbitrary code execution on every developer machine and CI runner that installs the package.
- Theft of whatever secrets are in that environment, including tokens that allow publishing more malicious packages.
Detect
01Inventory which dependencies have install scripts
npm queryselects packages in the installed tree with CSS-like selectors. Most projects have only a handful of packages that genuinely need install scripts. Every other entry on this list is attack surface you're accepting for nothing.terminal$ npm query ':attr(scripts, [postinstall]), :attr(scripts, [install]), :attr(scripts, [preinstall])' | jq -r '.[] | "\(.name)@\(.version) \(.scripts.postinstall // .scripts.install // .scripts.preinstall)"'── output ──esbuild@0.25.4 node install.jsbcrypt@5.1.1 node-pre-gyp install --fallback-to-buildcore-js@3.41.0 node -e "try{require('./postinstall')}catch(e){}"02Review new dependencies before they land
Dependency changes deserve the same review as code: a PR adding a package should say why. Tools like Socket or OpenSSF's package-analysis flag behaviour such as install scripts, network access, and obfuscated code in new or updated packages. GitHub's dependency review action shows new packages and their advisories in the PR itself.
.github/workflows/dependency-review.ymlwhole fileyaml on: pull_request permissions: { contents: read } jobs: review: runs-on: ubuntu-latest steps: - uses: actions/checkout@<sha> # v4 - uses: actions/dependency-review-action@<sha> # v4 with: fail-on-severity: high
Defend
011. Don't run install scripts by default
With
ignore-scripts=true, npm installs files without executing lifecycle scripts. Packages that really need a build step are rebuilt explicitly. pnpm goes further: since v10 it doesn't run dependencies' install scripts unless they're listed inonlyBuiltDependencies, which is an allowlist you review.Vulnerable
.npmrcwhole fileini # (empty: every dependency's install scripts run automatically)Hardened
.npmrc (npm) · package.json (pnpm)whole fileini # .npmrc ignore-scripts=true # then, only for the packages that need it: # npm rebuild esbuild bcrypt # package.json (pnpm >= 10) # "pnpm": { "onlyBuiltDependencies": ["esbuild", "bcrypt"] }022. Install without secrets in the environment
Split CI so the dependency install runs in a step (or job) that has no deploy or cloud credentials. Registry auth, if needed, is a read-only token scoped to installs. Publishing uses npm's trusted publishing via OIDC from one protected workflow, not a long-lived token sitting in every job.
Vulnerable
.github/workflows/ci.ymladd to fileyaml env: # every step, including npm install, sees these NPM_TOKEN: ${{ secrets.NPM_TOKEN }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_KEY }} steps: - run: npm installHardened
.github/workflows/ci.ymladd to fileyaml permissions: { contents: read } steps: - run: npm ci --ignore-scripts # no secrets in this environment - run: npm rebuild esbuild bcrypt # the reviewed allowlist - run: npm test # deploys happen in a separate job/environment, using OIDC033. Lockfile discipline and a registry you control
Commit the lockfile and use
npm ci, so installs never silently pick up a new version. Configure scoped registries so internal package names can't resolve to the public registry (dependency confusion). Many organisations proxy the public registry through Artifactory, Nexus, or GitHub Packages, which lets them block known-malicious versions centrally and add a minimum-age delay before new releases are usable.Hardened
.npmrcadd to fileini ignore-scripts=true @acme:registry=https://npm.pkg.github.com/ # internal scope never resolves publicly registry=https://artifactory.acme.internal/api/npm/npm-remote/
Verify
01Install and test with scripts disabled
The app builds and tests pass with only the allowlisted rebuilds. If something breaks, you've discovered a dependency that genuinely needs a script. Review it and add it to the allowlist deliberately.
terminal$ rm -rf node_modules && npm ci --ignore-scripts && npm rebuild esbuild bcrypt && npm test── output ──added 214 packages in 3srebuilt dependencies successfully2 passing
The concepts
Trust is transitive
Adding one dependency trusts its maintainers, all ITS dependencies' maintainers, their accounts' security, and their build pipelines. A single compromised account anywhere in that graph can reach you. Fewer, well-maintained dependencies; pinned versions; delayed adoption of brand-new releases; and not running their code at install time all shrink that trust.
SLSA and provenance
SLSA (Supply-chain Levels for Software Artifacts) is a framework for proving how an artifact was built: from which source, by which build system, with what inputs. npm's provenance statements (published with --provenance from CI) let consumers verify a package was built from a given repo and workflow, which makes it much harder to publish a malicious version from a stolen laptop token.
Your turn
How can you check whether an installed npm package was published with provenance?
What is dependency confusion and what single config change prevents it for npm?
Interview questions
How do you reduce the risk of malicious open-source packages?