Supply Chain Attacks Hit Every Ecosystem. Here's How to Defend Yours.
A three-layer defense for developers and DevOps engineers: harden your dev machine in 5 minutes, lock down project configs for every ecosystem, and enforce policy org-wide with a proxy. Covers npm, pnpm, yarn, bun, pip, uv, Go, Maven, Gradle, NuGet, Cargo, and Composer — plus escrow, JFrog Curation, and Nexus.
Supply Chain Attacks Hit Every Ecosystem. Here’s How to Defend Yours.
Supply chain attacks on package registries are no longer an npm problem. In March 2026, axios was compromised via a stolen maintainer token — two poisoned versions published, a cross-platform RAT phoning home within two seconds of npm install. The same attack pattern has hit PyPI, RubyGems, and Maven Central. The install step is the attack surface, and it exists in every language.
This post covers three independent layers of defense. Stop at any layer — each one meaningfully reduces your exposure. Add the next when you’re ready.
| Layer | What it protects | Who sets it up | Time |
|---|---|---|---|
| 1 — Dev machine | Your installs, on your machine | You | 5 min |
| 2 — Project | Everyone who clones the repo, regardless of their machine config | Team lead / dev | 15 min |
| 3 — Org proxy | Every install org-wide — including CI and ecosystems with no native age gate | Platform / DevOps | 30 min |
Layer 1 — Protect your dev machine
Three changes protect you from the most common attack patterns before you write a single line of project code.
1. Harden npm
Add these flags to your global ~/.npmrc:
# ~/.npmrc — global npm security baseline (npm >= 11.15)min-release-age=7 # quarantine window — blocks packages < 7 days oldignore-scripts=true # block postinstall RCEallow-git=false # block git-dependency execution (npm >= 11.10)allow-file=none # block installs from local tarballs / file: paths (npm >= 11.15)allow-remote=none # block installs from https:// / http:// URLs (npm >= 11.15)allow-directory=none # block installs from local directories (npm >= 11.15)Or in one shot:
npm config set min-release-age 7npm config set ignore-scripts truenpm config set allow-git falsenpm config set allow-file nonenpm config set allow-remote nonenpm config set allow-directory noneThe three allow-* flags are new in npm 11.15 and close a real attack surface: non-registry sources (local tarballs, remote URLs, local directories) are a common dependency confusion and CI injection vector. Setting them to none means only packages from the configured registry are accepted. Hat tip: Robert Slootjes (AWS Community Builder) for surfacing these — cryptika.com
npm 12 preview:
allow-gitwill change its default fromalltonone. Setting it explicitly now means your config is already forward-compatible.
2. Make the flags stick for GUI apps too
.npmrc covers CLI invocations. But VSCode, Cursor, Zed, and other GUI apps launch from the OS — they never source your shell profile, and bundled Node runtimes inside IDE extensions may not read ~/.npmrc at all.
The fix: set NPM_CONFIG_* env vars at the OS level so every process inherits them.
macOS — LaunchAgent (recommended, no root needed):
Save to ~/Library/LaunchAgents/com.npm-security.environment.plist and load it:
<string>launchctl setenv NPM_CONFIG_ALLOW_FILE none && launchctl setenv NPM_CONFIG_ALLOW_REMOTE none && launchctl setenv NPM_CONFIG_ALLOW_DIRECTORY none && launchctl setenv NPM_CONFIG_ALLOW_GIT false && launchctl setenv NPM_CONFIG_IGNORE_SCRIPTS true && launchctl setenv NPM_CONFIG_MIN_RELEASE_AGE 7</string>launchctl load ~/Library/LaunchAgents/com.npm-security.environment.plistPersists across reboots. Verify: launchctl getenv NPM_CONFIG_ALLOW_FILE → none
Shell profile (.zshrc / .bashrc) — covers new terminal sessions:
# BEGIN npm-security-envexport NPM_CONFIG_ALLOW_FILE=noneexport NPM_CONFIG_ALLOW_REMOTE=noneexport NPM_CONFIG_ALLOW_DIRECTORY=noneexport NPM_CONFIG_ALLOW_GIT=falseexport NPM_CONFIG_IGNORE_SCRIPTS=trueexport NPM_CONFIG_MIN_RELEASE_AGE=7# END npm-security-envLinux — /etc/profile.d/ (system-wide):
sudo tee /etc/profile.d/npm-security.sh <<'EOF'export NPM_CONFIG_ALLOW_FILE=noneexport NPM_CONFIG_ALLOW_REMOTE=noneexport NPM_CONFIG_ALLOW_DIRECTORY=noneexport NPM_CONFIG_ALLOW_GIT=falseexport NPM_CONFIG_IGNORE_SCRIPTS=trueexport NPM_CONFIG_MIN_RELEASE_AGE=7EOFIf you’re running escrow, escrow-cli automates all of this in two commands:
escrow-cli config write-env # LaunchAgent (macOS) or /etc/profile.d/ (Linux)escrow-cli config write-shell # .zshrc + .bashrc3. Real-time install verification
The .npmrc flags are static gates. These wrapper tools add real-time threat intelligence at install time:
| Tool | By | Install | Age gate | Ecosystems |
|---|---|---|---|---|
| safe-chain | Aikido | curl -fsSL …install-safe-chain.sh | sh | ✓ 48 h default | npm/pnpm/yarn/bun + pip/uv/poetry/pdm |
| sfw | Socket | npm i -g sfw | ✗ | npm/yarn/pnpm/pip/uv/cargo |
| scfw | Datadog | pipx install scfw | ✗ | npm, pip, poetry |
All three use the same “prefix your command” model: safe-chain npm install lodash. safe-chain is the only one that also enforces a release-age gate. sfw and scfw check against known-malware databases only.
Layer 2 — Harden your projects
Config committed to the repo means every developer, every CI run, and every environment gets the same policy — even before they’ve set up their machine.
The two controls that matter
1. Minimum release age. Most supply chain attacks follow the same pattern: compromise a package, publish a malicious version, hope developers install it before detection. A quarantine window breaks this. 7 days is long enough for the community to notice and for registries to yank the version.
2. Block install scripts. postinstall hooks run arbitrary code the moment you install a package — before you even import it. Disabling them globally, with explicit opt-in for the handful of packages that legitimately need them, eliminates this vector entirely.
What each ecosystem can enforce
| Ecosystem | Runs code on install | Native age gate | Script blocking |
|---|---|---|---|
| npm / pnpm / yarn / bun | ✓ postinstall | ✓ client-side | ✓ |
| Python (pip / uv) | ✓ sdist only | ✓ uv only | ✓ only-binary |
| PHP / Composer | ✓ post-install-cmd | ✗ | ✓ --no-scripts |
| Go | — | ✗ | n/a |
| Maven / Gradle | — (build only) | ✗ | n/a |
| NuGet | — (build only) | ✗ | n/a |
| Rust / Cargo | ✓ build.rs | ✗ | — (no off switch) |
Ecosystems without a native age gate need a proxy (Layer 3) to enforce the quarantine window.
Quick reference
| Ecosystem | Tool | Config file | Min age (7 days) | Block scripts |
|---|---|---|---|---|
| Node.js | npm >= 11.15 | .npmrc | min-release-age=7 | ignore-scripts=true |
| Node.js | pnpm v10 >= 10.16 | .npmrc | minimumReleaseAge=10080 | ignore-scripts=true |
| Node.js | pnpm v11 | .npmrc + pnpm-workspace.yaml | minimumReleaseAge=10080 | allowBuilds: {} |
| Node.js | yarn >= 4.10 | .yarnrc.yml | npmMinimalAgeGate: 10080 | enableScripts: false |
| Node.js | bun >= 1.3 | bunfig.toml | minimumReleaseAge = 10080 | ignore-scripts = true |
| Python | uv | uv.toml | exclude-newer = "P7D" | only-binary = [":all:"] |
| Python | pip | pip.conf | none — use proxy | only-binary = :all: |
| Go | go | env | none — use proxy | no install scripts |
| Rust | cargo | deny.toml | none — use proxy | no off switch |
| Java | Maven | settings.xml | none — use proxy | declared in pom.xml |
| Java | Gradle | gradle.properties | none — use proxy | declared in build.gradle |
| .NET | NuGet | nuget.config | none — use proxy | no install scripts |
| PHP | Composer | composer.json | none — use proxy | --no-scripts |
Units: npm uses days. pnpm, yarn, and bun use minutes (10080 min = 7 days).
Per-ecosystem config
npm (>= 11.15)
min-release-age=7ignore-scripts=trueallow-git=falseallow-file=noneallow-remote=noneallow-directory=noneregistry=https://registry.npmjs.org/CI: npm ci
pnpm v10
minimumReleaseAge=10080ignore-scripts=trueblockExoticSubdeps=trueregistry=https://registry.npmjs.org/CI: pnpm install --frozen-lockfile
pnpm v11 — breaking change
Build policy moved out of .npmrc — settings in .npmrc are silently ignored in v11.
# .npmrc — auth and registry ONLYminimumReleaseAge=10080registry=https://registry.npmjs.org/# pnpm-workspace.yaml — build policy lives hereallowBuilds: {}strictDepBuilds: trueblockExoticSubdeps: trueCI: pnpm install --frozen-lockfile
Yarn Berry (>= 4.10)
npmMinimalAgeGate: 10080enableScripts: falseenableHardenedMode: truechecksumBehavior: thrownodeLinker: node-modulesnpmRegistryServer: "https://registry.npmjs.org"CI: yarn install --immutable
Bun (>= 1.3)
[install]minimumReleaseAge = 10080ignore-scripts = trueregistry = "https://registry.npmjs.org/"CI: bun install --frozen-lockfile
uv (Python)
exclude-newer = "P7D"
[pip]require-hashes = trueonly-binary = [":all:"]CI: uv sync --frozen
pip
# pip.conf[global]require-hashes = trueonly-binary = :all:index-url = https://pypi.org/simple/No native age gate — use escrow or another proxy.
Go
export GOFLAGS="-mod=readonly"export GOPROXY="https://proxy.golang.org,off" # 'off' not 'direct'export GOSUMDB="sum.golang.org"CI: go mod verify && GOFLAGS=-mod=readonly go build ./...
Rust / Cargo
# deny.toml (cargo-deny >= 0.16)[advisories]db-urls = ["https://github.com/rustsec/advisory-db"]ignore = []
[licenses]allow = ["MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", "BSD-3-Clause", "ISC"]
[sources]unknown-registry = "deny"unknown-git = "deny"allow-registry = ["https://github.com/rust-lang/crates.io-index"]CI: cargo build --locked && cargo deny check advisories bans sources
Java / Maven
<mirror> <id>internal-mirror</id> <mirrorOf>*</mirrorOf> <url>https://nexus.internal.company.com/repository/maven-public/</url> <checksumPolicy>fail</checksumPolicy> <releases><updatePolicy>never</updatePolicy></releases></mirror>CI: mvn validate && mvn dependency-check:check
Java / Gradle
org.gradle.dependency.verification=strictCI: ./gradlew build --dependency-verification=strict
.NET / NuGet
<PropertyGroup> <RestoreLockedMode>true</RestoreLockedMode> <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> <NuGetAudit>true</NuGetAudit> <NuGetAuditMode>all</NuGetAuditMode> <NuGetAuditLevel>low</NuGetAuditLevel></PropertyGroup>CI: dotnet restore /p:RestoreLockedMode=true
PHP / Composer
{ "config": { "preferred-install": "dist", "allow-plugins": {} }}CI: composer install --no-scripts --prefer-dist --no-interaction
Layer 3 — Org-wide enforcement
Client-side flags protect individual machines. A proxy enforces policy at a single chokepoint — every install from every developer, every CI runner, and every environment goes through it. It is also the only option for ecosystems with no native age gate: Go, Cargo, Maven, NuGet, and Composer cannot quarantine packages without a proxy.
escrow — recommended (7 ecosystems, age + OSV)
escrow v1.5.0 is a purpose-built supply-chain proxy covering seven ecosystems (npm, PyPI, Go, Cargo, NuGet, Maven, Composer) in a single binary. It enforces age gates, OSV vulnerability checks, and publisher-account-age policy server-side, with a real-time dashboard for reviewing and approving blocked packages.
# Homebrew (macOS — auto-starts as a service)brew tap jverhoeks/tap && brew install escrowbrew services start escrow# Dockerdocker run -p 7888:7888 ghcr.io/jverhoeks/escrow:latest[server] port = 7888
[ecosystems] npm = true pypi = true go = true cargo = true nuget = true maven = true composer = true
[policy.age] min_days = 7 action = "block"Route all tools in one command:
escrow-cli config write # writes ~/.npmrc, ~/.cargo/config.toml, etc.escrow-cli config write-env # LaunchAgent / profile.d — GUI appsescrow-cli config write-shell # .zshrc + .bashrcPoint each tool at port 7888: registry=http://localhost:7888/ for npm/pnpm/bun, index-url = http://localhost:7888/pypi/simple/ for pip/uv, GOPROXY=http://localhost:7888/go,off for Go, and so on. See the full repo README for every ecosystem’s routing config.
JFrog Curation — commercial, if you’re on the JFrog platform
JFrog Curation is the commercial equivalent. It intercepts package requests in front of Artifactory’s remote repositories and applies policy before packages enter your SDLC.
The key feature is the time-delay / immature package policy: blocks newly published versions until they reach a configurable minimum age (14 days cited as example). When a package is blocked, Curation can silently substitute a safe older version — useful for CI pipelines that can’t tolerate hard failures, but it reduces visibility compared to escrow’s explicit approval dashboard.
| JFrog Curation | escrow | |
|---|---|---|
| Age gate | ✓ configurable | ✓ configurable |
| Ecosystems | npm, PyPI, Maven, Go (Cargo: partial) | npm, PyPI, Go, Cargo, NuGet, Maven, Composer |
| OSV / malware | ✓ via Xray | ✓ |
| Block behaviour | substitutes older version | blocks + dashboard |
| Cost | 💰 paid add-on | Free / OSS |
Verify Cargo support with JFrog before relying on Curation for Rust — docs describe it as “varying levels of support.”
Nexus — if you already run it
Nexus Repository OSS is the right choice when your org already operates it or needs its breadth (40+ package formats). Nexus does not enforce a release-age gate server-side — the quarantine window stays client-side, and it does not cover Cargo (Pro only).
docker compose up -d nexus && bash nexus/setup.sh| Tool | Nexus URL |
|---|---|
| npm / pnpm / bun | http://localhost:8081/repository/npm-proxy/ |
| pip / uv | http://localhost:8081/repository/pypi-proxy/simple/ |
| Go | http://localhost:8081/repository/go-proxy,off |
| Maven | http://localhost:8081/repository/maven-central/ |
| NuGet | http://localhost:8081/repository/nuget-proxy/index.json |
| Composer | http://localhost:8081/repository/composer-proxy/ |
Nexus uses ~1.5 GB RAM at rest. Nexus CE 3.70+ requires EULA acceptance — run
setup.shorPOST /service/rest/v1/system/eula {"accepted":true}.
Per-ecosystem lightweight alternatives
For single-ecosystem needs or the lowest possible footprint:
| Proxy | Ecosystem | Age enforcement |
|---|---|---|
| Verdaccio | npm / pnpm / yarn / bun | Server-side |
| devpi | pip / uv | Client-side only |
| Athens | Go modules | Client-side only |
| Reposilite | Maven / Gradle | Client-side only |
| BaGetter | NuGet | Client-side only |
Things Most Teams Miss
ignore-scripts=true does not block git dependencies.
npm calls the system git binary to fetch git-hosted packages, bypassing the lifecycle hook system. A malicious package can override which binary npm treats as git. The fix: allow-git=false (npm >= 11.10).
pnpm v11 moved build policy to pnpm-workspace.yaml.
allowBuilds in .npmrc is silently ignored in v11. If you upgraded from v10, your build policy may be doing nothing.
npm uses days, not minutes.
min-release-age=7 = 7 days in npm. minimumReleaseAge=7 = 7 minutes in pnpm. Both the key and the unit differ.
GOPROXY=...,direct is not safe.
direct silently bypasses the proxy when it’s unreachable. Use off — fail loudly instead.
Maven checksumPolicy defaults to warn.
A tampered artifact produces a warning in the build log and then installs. Set checksumPolicy=fail.
Composer scripts run exactly like npm postinstall — but fewer teams know this.
Always use --no-scripts in CI. There is no persistent config key to disable them globally.
.npmrc flags don’t reach IDE-bundled runtimes.
VSCode, Cursor, and Zed bundle their own Node.js. That runtime may not read ~/.npmrc. Use launchctl setenv (macOS) or /etc/profile.d/ (Linux) to inject NPM_CONFIG_* env vars at the OS level.
A proxy does not replace client-side script blocking.
Every proxy — escrow, Nexus, Verdaccio — caches packages but does not strip postinstall hooks. The ignore-scripts, allowBuilds, and only-binary settings from Layer 2 are still required even when a proxy is in the path.
Cargo build.rs has no global off switch.
cargo vet and cargo deny are the only mitigations short of sandboxed builds.
What This Does Not Cover
- Dependency confusion: scoped packages (
@myorg/), single index only, private proxy as sole source - Typosquatting: lockfiles are the main mitigation
- SBOM generation:
mvn cyclonedx:makeAggregateBom,cargo cyclonedx,dotnet sbom-tool - Lockfile integrity in CI:
npm ci,pnpm install --frozen-lockfile,uv sync --frozen,cargo build --locked - Vulnerability scanning:
npm audit,pip-audit,cargo audit,govulncheck - SLSA / Sigstore: provenance attestation — see
cross-ecosystem/slsa-sigstore.md