Treat the suspect version as untrusted bytes. Inspect it with npm view, npm diff and npm pack --ignore-scripts instead of installing it, then find every build that installed it while credentials were present. Quarantine those outputs, revoke the credentials those runs could read, and rebuild on clean workers from the approved version. Prevent repeats with lockfile-only installs, blocked install scripts, a release-age cooldown and private names that resolve from one registry.
- Install hooks run before any import, so every build that installed the version may have exposed the credentials it held.
- Pinning the previous version stops new installs; it revokes nothing and does not clean outputs already built.
- Shai-Hulud (September 2025) spread by publishing with stolen npm tokens; its November 2025 wave ran from
preinstall. - Dependency confusion needs a private name that can also resolve from a public registry; one registry per scope or index closes it.
- npm 12 blocks dependency install scripts unless
allowScriptsapproves them; pnpm 10 and Yarn 4.14 and later block them by default too.
| Setting | Value | Note | Source |
|---|---|---|---|
| CI install command | npm ci, which fails when package.json and package-lock.json disagree | — | npm Docs: npm ci |
| Dependency install scripts | npm 12: blocked unless allowScripts approves them; npm 11: ignore-scripts=true | — | npm Docs: npm install-scripts |
| npm release-age cooldown | min-release-age=3 (days) | Use min-release-age-exclude for an urgent fix. | npm Docs: config |
| pnpm release-age cooldown | minimumReleaseAge 1440 minutes, the default since pnpm 11 | — | pnpm settings: dependency resolution |
| Git and remote tarball dependencies | allow-git=none and allow-remote=none, the npm 12 defaults | — | npm CLI changelog |
| Private npm packages | A scope such as @acme mapped with @acme:registry= in .npmrc | — | npm Docs: config |
| Python indexes | One --index-url plus --require-hashes; never --extra-index-url for private names | — | pip install documentation |
- Run the inspection script against the approved and suspect versions and record the changed files, scripts and integrity hash without installing anything.
- Search lockfiles and CI logs for the suspect integrity hash and list every run that installed it, with the credentials each run could read.
- Confirm that each revoked credential now fails and that quarantined outputs cannot be promoted.
- Run
npm config get min-release-age allow-git allow-remotein CI and expect the values committed in.npmrc. - On npm 12, run
npm install-scripts lsand confirm that only reviewed packages are approved inallowScripts.
- Applies to
- npm CLI 12.0.0 or later blocks dependency install scripts by default; 11.10.0 or later for min-release-age
- pnpm 10.26.0 or later for allowBuilds; 11.0.0 or later for the 1440-minute release-age default
- Yarn 4.14.0 or later blocks install scripts by default; 4.15.0 or later applies a one-day age gate
Suspected malicious dependency response. Analyze in an isolated environment without developer credentials.
- Part of
- Workflows: Fix a finding, Respond to a vulnerability
Learning goals and prerequisites
What you will learn
- Recognize install-time execution as part of the exposure boundary.
- Distinguish absent secrets from trustworthy build output.
- Plan containment and clean recovery without executing suspicious code.
Before you start
Engineers and incident responders handling a suspected malicious dependency, and teams hardening package installs
- Identify installation hooks, package integrity data, credential scope and affected builds.
Refresh first: Respond to an exploited or malicious dependency
Go to the guided practiceA suspicious package should first be treated as untrusted bytes. Installation can execute lifecycle hooks before an application imports the package, so reproducing an ordinary install on an analyst's workstation can create a new incident. Collect immutable package identity, source registry, archive digest, lockfile changes, publication timing and the relevant build runs. Compare the archive and metadata using a non-executing inspection path, such as the commands below. A repository tag is supporting context, not proof that registry bytes match it.
The fictional log-helper update adds a postinstall hook that reads an environment variable and writes a network request description. The fixture is a static transcript, not a runnable malicious package. The new hook is causally important because three CI builds installed it with credentials present. A minified file or a recently created maintainer account can be a clue, but neither alone proves malicious intent. Preserve evidence and separate confirmed behavior, plausible exposure and unanswered questions.
The npm CLI can answer the first questions without executing anything. npm view shows the declared scripts, the registry integrity hash and the publication time, npm diff lists files that changed between the approved and suspect versions, and npm pack with --ignore-scripts downloads the tarball so its manifest can be read with tar. Run the script below in a disposable directory that holds no credentials. The integrity value it prints is the same SHA-512 hash recorded in package-lock.json, so it also finds every lockfile and build log that pulled the suspect bytes.
#!/usr/bin/env bash
# Inspect a suspect npm version without installing it or running its scripts.
# Usage: ./inspect-package.sh log-helper 1.4.0 1.4.1
set -euo pipefail
pkg=$1 old=$2 new=$3
npm view "$pkg@$new" scripts dist.integrity dist.tarball --json
# npm 12 wraps view results in an array; npm 11 does not.
npm view "$pkg" time --json \
| jq -r --arg v "$new" '(if type == "array" then .[0] else . end) | "published: " + .[$v]'
npm diff --diff="$pkg@$old" --diff="$pkg@$new" --diff-name-only
# Download the tarball only, then read its manifest from the archive. npm 11 prints
# an array and npm 12 an object keyed by name; first(.[]) reads either.
packed=$(npm pack "$pkg@$new" --ignore-scripts --json)
jq -r 'first(.[]) | .filename, .integrity' <<<"$packed"
tar -xOzf "$(jq -r 'first(.[]) | .filename' <<<"$packed")" package/package.json | jq '.scripts // {}'Containment has two parts: prevent further installations or execution, and reduce what any already executed code can still do. Pinning the prior version stops a future resolver choice but does not revoke a credential copied during an earlier run. Remove suspect artifacts from promotion, isolate affected workers and review the secret/identity scope available in those runs. Rotate or revoke credentials when the evidence supports exposure, using the organization's incident process and recording the sequence so logs remain interpretable.
For this exercise, run B1 had token T with repository-write authority, B2 had no long-lived token but could request release identity, and B3 had no secret or publishing identity. B2's short-lived identity reduces persistence of the credential, but actions performed while it was valid may outlive it. Review publishing and deployment events as well as token expiry. B3 still produced untrusted build outputs; absence of credentials does not automatically rehabilitate those bytes. Containment may delay releases, so name the affected products and a safe rebuilding path.
OpenSSF Package Analysis illustrates automated observation of package behavior, but a sandbox result is only as complete as its instrumentation and trigger conditions. A package can act differently by environment, time or network response. Begin with static deltas: package scripts, newly included files, dependency changes and encoded payloads. If dynamic analysis is needed, use a disposable isolated environment with synthetic data, no real credentials, controlled network behavior and recorded tool configuration. Do not reconnect that environment to trusted build infrastructure afterward.
The strongest initial question here is narrower than prove the package is malicious: did the changed install path execute in a credential-bearing build? The run transcript answers yes for B1/B2. Network records show one outbound connection from B1 but not its content. That supports suspected token exposure without proving what was transmitted. Preserve raw logs, archive hash and a timeline; do not replace them with a scanner verdict. A negative sandbox result cannot erase the confirmed lifecycle execution in production CI.
Recovery requires a reviewed dependency choice, clean worker state, new credentials where needed and fresh outputs from that path. Reusing a cached executable or promoting an old affected build bypasses the repair. Verify the exact restored package digest, absence of the unauthorized hook, updated lockfile integrity and resulting release evidence. Investigate unauthorized publication and access during the exposure window; revocation prevents future use but cannot undo actions already performed.
Supply-chain risk guidance from NIST emphasizes managing supplier and product risk over the lifecycle. The operational record here connects that principle to a specific event: original observation, affected runs, accessible authority, actions taken, evidence locations, accountable owner and criteria for reopening releases. Mark facts and hypotheses separately. A reliable incident update can say three outputs quarantined and token T requires revocation while also saying exfiltration is unproven. That precision helps the response team act without overstating what the evidence establishes.
Response to a package compromise depends on how the code got in, because the controls that stop a repeat differ. In September 2025 the Shai-Hulud worm (/domains/supply-chain/dependencies/case-study-shai-hulud/) harvested npm, GitHub and cloud credentials from developer machines and CI runners and republished other packages with the stolen npm tokens; CISA reported more than 500 compromised packages. Its second wave, reported on 2025-11-24, ran from a preinstall hook, so it executed before installation finished. A month earlier, malicious nx releases published with a token stolen through a GitHub Actions injection ran a postinstall credential stealer. Two older npm incidents show the two takeover routes, a maintainer handover and an account hijack: /domains/supply-chain/dependencies/case-study-event-stream/.
| Pattern | How it reaches a build | Control that stops it |
|---|---|---|
| Typosquatting and slopsquatting | A developer or AI assistant adds a look-alike or invented package name. | Review every new direct dependency; install only from the lockfile; allowlist packages in the internal registry. |
| Dependency confusion | An internal package name also resolves from a public registry, which offers a higher version. | Use scoped names mapped to one registry, a single Python index, and NuGet package source mapping; register your scopes publicly. |
| Account or token takeover | A stolen maintainer credential or CI token publishes a new version of a real package. | Publishers: trusted publishing, staged approval and phishing-resistant two-factor authentication. Consumers: a release-age cooldown. |
| Self-propagating worm | Stolen tokens republish every package the victim can publish, as Shai-Hulud did in 2025. | Block install scripts, keep tokens short-lived, and rotate every credential found on an affected machine. |
| Install-time execution | A preinstall or postinstall hook runs during installation, before any code imports the package. | npm 12 allowScripts, pnpm allowBuilds, Yarn enableScripts: false, or ignore-scripts=true on npm 11. |
Commit the install policy so that every developer machine and CI job uses it. On npm 12, dependency install scripts stay blocked until npm install-scripts approve <pkg> records the package, pinned to its reviewed version, in the allowScripts field of package.json; npm rebuild then runs the approved scripts. min-release-age makes npm pick the newest version that is at least that many days old, so a compromised release that is detected and pulled within that window never reaches your builds. Dependabot applies a 3-day cooldown to version updates by default since 2026-07-14 and still opens security updates immediately. /domains/supply-chain/ci-cd/developer-workstation-security/ applies the same settings to developer machines, along with editor extensions and AI agents.
Dependency confusion is a resolution problem, covered per ecosystem in /domains/supply-chain/dependencies/dependency-confusion/. With --extra-index-url, pip treats every index as equal and installs the highest version it finds, so a public package can replace a private one with the same name. Point pip at one index that proxies the public one, or use uv, whose default first-index strategy stops at the first index that has the name. For npm, publish private packages under a scope you also own on npmjs.com and map that scope to the internal registry, as in the file below.
| Tool | Install from the lockfile | Install scripts | Release-age cooldown | Private names |
|---|---|---|---|---|
| npm 12 | npm ci | Blocked unless approved in allowScripts | min-release-age (days) | @scope:registry= in .npmrc |
| pnpm 11 or later | pnpm install --frozen-lockfile | Blocked unless allowed in allowBuilds | minimumReleaseAge (minutes, default 1440) | @scope:registry= in .npmrc |
| Yarn 4.15 or later | yarn install --immutable | enableScripts is false by default since 4.14 | npmMinimalAgeGate (default 1 day since 4.15) | npmScopes in .yarnrc.yml |
| pip | pip install --require-hashes -r requirements.txt | --only-binary :all: avoids running sdist builds | --uploaded-prior-to P3D | One --index-url |
| uv | uv sync --locked | --no-build avoids running sdist builds | exclude-newer = "3 days" | index-strategy = "first-index" (default) |
; Resolve the internal scope only from the internal registry.
@acme:registry=https://npm.acme.example/
; Skip versions published less than 3 days ago (npm 11.10 or later).
min-release-age=3
; Refuse git and remote tarball dependencies (the npm 12 defaults).
allow-git=none
allow-remote=none
; npm 11 only: ignore-scripts=true
; npm 12 blocks dependency install scripts unless package.json allowScripts approves them.Guided practice
Investigate a fictional suspicious npm update from static records. Do not install or execute a package.
Supplied scenario material
Package: pkg:npm/log-helper@1.4.1, archive digest H141; previous approved 1.4.0/H140.
Static delta E1: 1.4.1 adds postinstall=run bootstrap.js. Transcript of bootstrap.js: reads BUILD_TOKEN; constructs outbound request to collector.invalid; endpoint is illustrative and must not be contacted.
Runs: B1 installed H141 at 10:00 with token T (repo write), output O1. B2 installed H141 at 10:05 with release OIDC permission, output O2. B3 installed H141 at 10:10 without secrets/identity, output O3.
E2: postinstall executed in all runs. E3: B1 network log records a connection to the named host; payload unavailable. No network capture for B2/B3.
E4: release registry records one publication at 10:06 by B2 identity; authorization review pending.
Proposal: pin H140 and publish cached O3 immediately.
Fictional incident lead: Mara; release owner: Ellis.1. Separate observation and inference
List confirmed execution, suspected exposure and an unproven claim.
Expected result and reasoning
Confirmed: H141 added and executed a lifecycle hook in B1–B3; B1 made the recorded connection. Suspected: T may have been read/transmitted. Unproven: actual token exfiltration or malicious ownership of the package.
2. Scope containment
Name all outputs and authorities needing action.
Expected result and reasoning
Quarantine O1/O2/O3. Revoke/rotate T through the incident process, remove the suspect dependency from new builds, isolate worker/cache state, and examine B2 publication at 10:06. OIDC expiry alone does not revoke a package already published.
3. Reject the partial recovery
Evaluate pinning H140 and publishing O3.
Expected result and reasoning
Pinning helps future installs but does not make O3 trustworthy: it was built after H141 executed. Reject cached O3 and rebuild on a clean worker from reviewed H140 and protected inputs.
4. Plan one safe analysis
Choose an analysis that resolves an open question without running the package.
Expected result and reasoning
Compare registry publication at 10:06 with the approved release digest and change record. Preserve E4 and determine whether the publication was authorized. This can assess persistent impact without exposing another machine to the package.
5. Define release reopening
Write explicit recovery evidence and owner assignments.
Expected result and reasoning
Mara owns authority/exposure investigation; Ellis owns clean rebuild. Reopen only after H140 identity and hook absence are checked, compromised state excluded, required credentials revoked, and new output/provenance reviewed. Record unresolved payload visibility rather than claiming no exfiltration.
All package names, digests and events are fictional. No executable payload is provided and no endpoint should be contacted; this is incident reasoning from supplied records.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- OpenSSF Package Analysis
- GitHub Actions secure use reference
- NIST SP 800-161 Rev. 1, updated: supply-chain risk management. May 2022, updated 1 November 2024.
- CISA: widespread supply chain compromise impacting the npm ecosystem (2025-09-23)
- npm Docs: npm ci. npm v11.
- npm Docs: npm diff. npm v11.
- npm Docs: npm install-scripts. npm v12.
- npm Docs: config. npm v11.
- npm CLI changelog
- pnpm settings: dependency resolution
- pnpm settings: build
- Yarn configuration: .yarnrc.yml
- pip install documentation. pip 26.2.1.
- uv settings reference
- NuGet package source mapping
Reference information
- SDLC phase
- DevelopmentMaintenance
- ASVS 5.0.0 requirements
- v5.0.0-15.2.4
- CAPEC attack patterns