필사 모드: Anatomy of npm Supply Chain Attacks — Defense Strategies for the Era When Even Red Hat Got Hit
EnglishIntroduction — Even Red Hat Got Breached
In June 2026, one story dominated the Hacker News front page: malicious npm packages connected to RedHatInsights/javascript-clients, the official JavaScript client repository for Red Hat Cloud Services, had been discovered. Issue number 492 on that repository filled up with users reporting suspicious package versions and abnormal install script behavior, and translated summaries spread quickly on GeekNews, making it a hot topic in the Korean community as well.
The reason this incident was so shocking is simple. Red Hat is one of the most security-conscious vendors in the open source ecosystem, and a large number of enterprise customers install their client libraries on the basis of trust. The moment the assumption "it is an official scoped package from a famous vendor, so it must be safe" collapses, every npm install we run becomes a potential attack vector.
In truth, npm supply chain attacks are nothing new. From event-stream (2018) and ua-parser-js (2021) to node-ipc (2022) and the massive chain of token-stealing worm incidents that erupted in 2025, they get more sophisticated every year. What has changed is the caliber of the targets. We have entered an era in which not a lone maintainer side project but the official packages and build infrastructure of major vendors are directly in the crosshairs.
In this post, I will systematically classify the types of npm supply chain attacks and walk through a defense stack that applies from individual developers up to the organizational level, centered on code and configuration examples.
The Five Types of Supply Chain Attacks
You have to classify the attack types first before you can map defenses to them. Attacks observed in the npm ecosystem fall into roughly five categories.
| Type | Attack method | Representative case | Key defense |
| --- | --- | --- | --- |
| Typosquatting | Register packages with similar names | crossenv vs cross-env | Dependency review, scanners |
| Account takeover | Compromise a maintainer npm account | ua-parser-js | Enforced 2FA, provenance |
| Install scripts | Execute malicious code in postinstall | eslint-scope | ignore-scripts |
| Dependency confusion | Squat internal package names on the public registry | 2021 Birsan research | Scope pinning, registry config |
| Build infrastructure compromise | Breach the CI or release pipeline itself | SolarWinds, 2026 Red Hat incident | Isolated builds, signature verification |
Let us look at each type in a bit more depth.
1. Typosquatting
The most classic technique. An attacker registers a malicious package whose name differs from a popular package by one or two characters and waits for a developer to make a typo. A variant is combosquatting, which combines the names of real packages into something plausible, like react-dom-router.
2. Account Takeover
The attacker obtains a maintainer npm account credential via phishing or credential stuffing, then publishes a new version of a legitimate package with malicious code embedded. Because the package name is genuine, this is far more dangerous than typosquatting. In the 2021 ua-parser-js incident, a package with millions of weekly downloads shipped with a cryptocurrency miner and credential-stealing code.
3. Install Script Abuse
npm automatically executes preinstall, install, and postinstall scripts when a package is installed. That means arbitrary code runs at install time even if you never import a single line of the code. In the large worm incident of 2025, the malicious code targeted exactly this point. It harvested environment variables, npm tokens, and cloud credentials from developer machines, exfiltrated them, and used the stolen npm tokens to infect yet more packages in a self-replicating structure.
4. Dependency Confusion
If an internal-only package name is not registered on the public registry, an attacker can publish a higher version under the same name publicly. A build environment with misconfigured registry priority will pull the malicious public package instead of the internal one. In 2021, Alex Birsan demonstrated code execution inside the internal networks of 35 companies including Apple and Microsoft with this technique, earning more than 130,000 dollars in bug bounties.
5. Build Infrastructure Compromise
The most sophisticated type and the hardest to defend against. The package source code is clean, but malicious code is injected during the build pipeline or release stage. This is also why the 2026 Red Hat incident drew so much attention. When something in an organization release machinery is compromised rather than a single maintainer, source code review alone cannot detect it.
The Attack Surface at a Glance
Developer machine Registry (npm) Consumer CI / production
+------------------+ +-----------------+ +------------------+
| Source code |--pub-->| Package tarball |--install->| node_modules |
| npm token | | Metadata | | postinstall runs |
| .npmrc | | dist-tags | | Bundled in app |
+------------------+ +-----------------+ +------------------+
^ ^ ^
| | |
[account takeover] [typosquatting] [install scripts]
[token leakage] [dependency confusion] [lockfile bypass]
| |
[build infra compromise] -- very hard to detect when source and tarball differ
The key insight is this: the source repository (GitHub) and the tarball uploaded to the registry are separate artifacts. Even if you review the code on GitHub, nothing guarantees that the tarball actually being installed contains the same code. Closing that gap is what provenance, covered below, is for.
First Line of Defense — Blocking Install Scripts
Start with the defense that gives the most value for the least cost. Add the following to .npmrc at the project root or in your home directory.
.npmrc — block automatic execution of install scripts
ignore-scripts=true
Side benefit: scope pinning to defend against dependency confusion
@mycompany:registry=https://npm.internal.mycompany.com/
always-auth=true
Audit level
audit-level=high
With ignore-scripts on, packages that build native binaries such as esbuild or sharp may stop working. The standard approach is to block everything first and then allowlist what you need. pnpm supports this as a first-class feature.
pnpm v10+ — pnpm-workspace.yaml
Block all install scripts by default; allow only the listed packages
onlyBuiltDependencies:
- esbuild
- sharp
- better-sqlite3
If you use npm, a practical pattern is to keep ignore-scripts on and add a script that manually rebuilds just the packages that need it after installation.
{
"scripts": {
"postdeps": "npm rebuild esbuild sharp",
"deps": "npm ci --ignore-scripts && npm run postdeps"
}
}
The fact that pnpm switched to blocking install scripts by default after the 2025 worm incident shows where the whole ecosystem is heading. Reducing the total amount of code that executes is the starting point of every defense.
Second Line of Defense — Lockfile Integrity
A lockfile is not merely a version pinning device; it is an integrity verification device. Every entry in package-lock.json carries an integrity field.
{
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
}
}
The integrity value is the SHA-512 hash of the tarball. npm ci aborts the installation if the hash does not match. This is why CI must always use npm ci, never npm install. npm install can quietly rewrite the lockfile, whereas npm ci fails when the lockfile and package.json disagree.
Two more pieces complete the lockfile defense.
First, verify that the lockfile itself has not been tampered with. Attacks have actually been reported in which a PR changes the resolved URLs in the lockfile to point somewhere other than the official registry. lockfile-lint blocks this.
npx lockfile-lint \
--path package-lock.json \
--allowed-hosts npm \
--validate-https \
--validate-integrity
Second, make dependency changes visible to humans in PRs. The GitHub dependency-review-action checks newly added dependencies in a PR for known vulnerabilities and license issues.
.github/workflows/dependency-review.yml
name: Dependency Review
on: [pull_request]
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/dependency-review-action@v4
with:
fail-on-severity: high
deny-licenses: AGPL-1.0-only, AGPL-3.0-only
Third Line of Defense — Provenance and Sigstore Signing
npm provenance is what solves the source-versus-tarball gap mentioned earlier. Since 2023, npm has supported Sigstore-based provenance attestations: cryptographic metadata proving which commit of which repository a package was built from, and by which CI workflow.
On the publishing side (the maintainer), you configure GitHub Actions like this.
.github/workflows/publish.yml
name: Publish Package
on:
release:
types: [published]
permissions:
contents: read
id-token: write # required for Sigstore OIDC signing
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 publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
The crucial part is the id-token permission. The workflow uses a GitHub OIDC token to obtain a short-lived Fulcio certificate from Sigstore for signing, and the signature is recorded in a public transparency log (Rekor). Because no long-lived signing key exists at all, the entire attack vector of key theft disappears.
On the consuming side, you can verify the signatures and attestations of installed packages.
Verify registry signatures and provenance attestations in one pass
npm audit signatures
You also need to understand the limits of provenance. Provenance proves only that "this tarball was built from this commit of this repository"; it does not prove that the code in that commit is safe. It makes build-infrastructure-compromise attacks harder, but it cannot stop a malicious commit itself. Moreover, as of 2026 only a fraction of packages publish with provenance, so a blanket policy of "block any package without provenance" is still impractical.
Dependency Minimization and the Vendoring Debate
Before any technical defense, there is a structural question: do we really need this many dependencies in the first place?
An average Node.js project drags in dozens of direct dependencies and a thousand or more transitive ones. As the left-pad affair showed, a culture of installing a package for a one-line function has grown the attack surface exponentially.
Here are practical decision criteria.
| Situation | Recommendation |
| --- | --- |
| Implementable yourself in under 20 lines | Write it yourself (no new dependency) |
| Replaceable by the standard library | Use built-in Node fetch, structuredClone, etc. |
| Small but correctness-critical logic | Copy the code in (vendoring) plus a source comment |
| Large, complex library | Add the dependency and check its Scorecard rating |
The vendoring debate (copying dependency sources directly into your repository) resurfaces on HN periodically. Proponents cite supply chain attack immunity and build reproducibility; opponents point to missed security patches and maintenance burden. The balance point is clear: vendor small utilities on your critical path, and keep large, actively patched libraries as dependencies paired with an automated update pipeline.
When evaluating a new dependency, OpenSSF Scorecard gives you objective metrics.
Check the security hygiene of a dependency candidate
npx @ossf/scorecard-cli --repo=github.com/sindresorhus/got
Key checks:
- Maintained: commit/issue activity within the last 90 days
- Code-Review: whether PR review is enforced
- Signed-Releases: whether releases are signed
- Dangerous-Workflow: risky patterns in CI config
- Token-Permissions: least-privilege GitHub tokens
The Organization-Level Defense Stack
Security that relies on individual vigilance always fails. For an organization, I recommend the following four-layer stack.
+--------------------------------------------------------------+
| Layer 4: Policy/Audit OpenSSF Scorecard, SBOM, regular audit|
+--------------------------------------------------------------+
| Layer 3: Scanners Socket / Snyk — behavior-based |
+--------------------------------------------------------------+
| Layer 2: Update control Renovate delay policy (cooldown) |
+--------------------------------------------------------------+
| Layer 1: Registry Internal proxy registry (quarantine) |
+--------------------------------------------------------------+
Layer 1 — Internal Proxy Registry
With a proxy registry such as Verdaccio, JFrog Artifactory, or Sonatype Nexus, all npm traffic in the organization passes through a single gate. When a malicious package is discovered, you can block it at the gate for everyone at once, and builds keep working from cache even when the upstream is down.
verdaccio config.yaml (excerpt)
uplinks:
npmjs:
url: https://registry.npmjs.org/
cache: true
packages:
'@mycompany/*':
access: $authenticated
publish: $authenticated
No upstream lookup for internal scope — kills dependency confusion at the root
'**':
access: $all
proxy: npmjs
Omitting the proxy setting for internal scoped packages is the heart of the dependency confusion defense. It severs the path by which internal names leak out and get resolved against the public registry.
Layer 2 — Renovate Delay Policy
Looking at the time pattern of supply chain attacks, it usually takes hours to days between a malicious version being published and it being discovered and taken down. Conversely, simply not pulling new versions immediately and letting them mature for a fixed period avoids most attacks.
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"minimumReleaseAge": "7 days",
"internalChecksFilter": "strict",
"packageRules": [
{
"matchUpdateTypes": ["patch", "minor"],
"minimumReleaseAge": "7 days"
},
{
"matchUpdateTypes": ["major"],
"minimumReleaseAge": "14 days"
},
{
"matchDepTypes": ["devDependencies"],
"minimumReleaseAge": "3 days"
}
],
"vulnerabilityAlerts": {
"minimumReleaseAge": "0 days"
}
}
The last block matters. Security patches must be exempted so they arrive without delay; otherwise the delay policy backfires by extending your vulnerability exposure window.
Layer 3 — Behavior-Based Scanners
Traditional vulnerability scanners (matching against CVE databases) only catch known vulnerabilities. Supply chain attacks do their damage before becoming known, so a tool like Socket that analyzes package behavior is a necessary complement. If a new version suddenly adds network calls, environment variable access, obfuscated code, or an install script, it warns at the PR stage.
socket.yml — project root
version: 2
issueRules:
installScripts: error
obfuscatedFile: error
envVars: warn
networkAccess: warn
shellAccess: error
Layer 4 — SBOM and Regular Audits
When an incident hits, you must be able to answer "are we using that package, and in which services?" within five minutes. Generate and retain an SBOM in CycloneDX or SPDX format on every build.
Generate a CycloneDX SBOM
npx @cyclonedx/cyclonedx-npm --output-file sbom.json
Instantly query whether a specific package is in use
jq '.components[] | select(.name == "compromised-pkg") | .version' sbom.json
Isolation in CI — Network-Blocked Builds
If you are in an environment where install scripts cannot be disabled, isolate them so that execution causes no harm. The key is egress control during the build stage.
On GitHub Actions, harden-runner is the standard choice.
.github/workflows/build.yml
name: Hardened Build
on: [push, pull_request]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: block
allowed-endpoints: >
registry.npmjs.org:443
npm.internal.mycompany.com:443
github.com:443
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci --ignore-scripts
- run: npm run build
- run: npm test
With egress-policy set to block, every outbound connection outside the allowlist is blocked and logged. There were numerous reports during the 2025 worm incident that the exfiltration step — sending credentials to an external server — was caught by exactly this defense.
In self-hosted environments, implement the same control at the container level.
Networkless build container — dependencies come from a pre-warmed cache volume
docker run --rm \
--network=none \
-v "$PWD:/app" \
-v npm-cache:/root/.npm \
-w /app \
node:22-slim \
sh -c "npm ci --offline --ignore-scripts && npm run build"
npm ci --offline fails when it encounters a package missing from the cache, so this becomes a two-phase pattern: cache warming (network allowed, scripts blocked) and building (network blocked).
Additionally, eliminating the npm token used in CI altogether is important. If you use a read-only mirror, or issue short-lived OIDC-based tokens only at the publish stage, then even a compromised CI has no long-lived token to steal.
Incident Response Procedure
If you do not document the procedure for when defenses fail ahead of time, you will be improvising on the day of the incident. A minimal runbook looks like this.
[T+0 min] Detection
- Awareness via scanner alert, HN/security advisory, or internal anomaly
- Pin down the affected package name and malicious version range
[T+15 min] Exposure assessment
- Query SBOM: extract the list of services/repos using the package
- Check lockfile history: confirm when the malicious version was actually installed
- Understand the behavior of the malicious version: token stealer or backdoor?
[T+30 min] Containment
- Block the malicious version at the internal registry
- Pause CI pipelines of affected repos
- Identify deployed artifacts that include the malicious version
[T+1 hour] Credential rotation
- Invalidate every secret in environments where the malicious version ran
- Rotate in order: npm tokens, cloud keys, CI secrets, SSH keys
- For token stealers, audit git push history and npm publish history
[T+1 day] Recovery and postmortem
- Pin to a safe version and regenerate the lockfile
- Document the timeline, identify detection gaps
- Feed prevention items back into the defense stack
A common mistake at the credential rotation step is the judgment "our code does not call any function from that package, so we are fine." Install-script attacks have already executed at install time, regardless of whether the code is used. If there is an installation record, treat it as exposed.
Practical Checklist
For individual developers:
[ ] Set ignore-scripts=true in .npmrc
[ ] Habitually use npm ci instead of npm install (enforce in CI)
[ ] Enable 2FA on your npm account (hardware key if possible)
[ ] No permanent npm tokens stored locally (granular token + expiry)
[ ] Before adding a dependency: check download trends, maintainers, Scorecard
[ ] Run npm audit signatures periodically
For organizations:
[ ] Deploy an internal proxy registry; block upstream lookups for internal scopes
[ ] Renovate minimumReleaseAge of 7+ days, with a security patch exception
[ ] Install a behavior-based scanner as a PR gate
[ ] CI egress policy set to block plus allowlist
[ ] Generate and retain an SBOM on every build
[ ] Apply provenance signing to published packages
[ ] Write an incident response runbook and drill it quarterly
[ ] Register lockfile-lint and dependency-review as required checks
Pitfalls and Counterarguments — A Critical View
The defense stack in this post has limits and counterarguments of its own. Let us face them honestly.
First, ignore-scripts is not a silver bullet. Even with install scripts blocked, malicious code embedded in the package body executes the moment you require it. Blocking install scripts only stops the automatic execution stage of an attack; it does not solve the trust problem of the code itself.
Second, delay policies are a probability game. A seven-day cooldown only works against attacks that are discovered quickly. Against a sophisticated backdoor that lurks for months (recall the xz-utils incident), it is powerless. Even so, since statistically most malicious npm packages are discovered within days, the cost-benefit remains strongly positive.
Third, the tool stack itself is a new dependency. Scanner vendors, registry proxies, and CI security actions are also third-party code and can be compromised. Adding a security tool like harden-runner without verification is ironic, so security tools in particular deserve strict version pinning (SHA pins) and origin verification.
Pin actions to a commit SHA, not a tag
- uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
Fourth, the developer experience cost is real. Cooldown periods mean you cannot use a new feature release immediately, and network-blocked builds make debugging harder. If security policy breeds a bypass culture (building on a personal laptop and uploading the result, for example), risk actually increases. Formalizing an exception process when introducing policy is essential.
Fifth, fundamentally this is a problem of ecosystem structure. Individual organizational defenses can only go so far; structural changes at the npm registry level — broader 2FA enforcement, provenance by default, install scripts blocked by default — must proceed in parallel. OpenSSF and npm are moving in that direction, but slowly.
Closing Thoughts
The 2026 Red Hat incident forced us to redefine the very notion of a "trusted vendor." Trust should come not from a name but from verifiable evidence: signatures, provenance, behavioral analysis.
In summary:
1. Today: ignore-scripts, npm ci, 2FA. Nearly zero cost, and it removes the largest attack vectors.
2. This quarter: Renovate delay policy, lockfile-lint, dependency-review, a scanner gate.
3. Within the year: internal registry, CI egress control, SBOM, provenance signing, incident response drills.
Supply chain security is not a one-time rollout but an operation. There is no perfect defense, but raising the attacker cost high enough is achievable. And most of that cost-raising work, as we have seen, starts with a few lines in a config file.
References
- RedHatInsights javascript-clients issue 492: https://github.com/RedHatInsights/javascript-clients/issues/492
- npm provenance official docs: https://docs.npmjs.com/generating-provenance-statements
- OpenSSF (Open Source Security Foundation): https://openssf.org/
- OpenSSF Scorecard: https://github.com/ossf/scorecard
- npm ci official docs: https://docs.npmjs.com/cli/v10/commands/npm-ci
- Sigstore project: https://www.sigstore.dev/
- SLSA (Supply-chain Levels for Software Artifacts): https://slsa.dev/
- lockfile-lint: https://github.com/lirantal/lockfile-lint
- Renovate configuration options (minimumReleaseAge): https://docs.renovatebot.com/configuration-options/
- Socket — behavior-based supply chain scanner: https://socket.dev/
- Verdaccio internal registry: https://verdaccio.org/
- step-security harden-runner: https://github.com/step-security/harden-runner
- Hacker News: https://news.ycombinator.com/
- GeekNews: https://news.hada.io/
현재 단락 (1/276)
In June 2026, one story dominated the Hacker News front page: malicious npm packages connected to Re...