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.

LayerWhat it protectsWho sets it upTime
1 — Dev machineYour installs, on your machineYou5 min
2 — ProjectEveryone who clones the repo, regardless of their machine configTeam lead / dev15 min
3 — Org proxyEvery install org-wide — including CI and ecosystems with no native age gatePlatform / DevOps30 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 old
ignore-scripts=true # block postinstall RCE
allow-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:

Terminal window
npm config set min-release-age 7
npm config set ignore-scripts true
npm config set allow-git false
npm config set allow-file none
npm config set allow-remote none
npm config set allow-directory none

The 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-git will change its default from all to none. 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 &amp;&amp;
launchctl setenv NPM_CONFIG_ALLOW_REMOTE none &amp;&amp;
launchctl setenv NPM_CONFIG_ALLOW_DIRECTORY none &amp;&amp;
launchctl setenv NPM_CONFIG_ALLOW_GIT false &amp;&amp;
launchctl setenv NPM_CONFIG_IGNORE_SCRIPTS true &amp;&amp;
launchctl setenv NPM_CONFIG_MIN_RELEASE_AGE 7</string>
Terminal window
launchctl load ~/Library/LaunchAgents/com.npm-security.environment.plist

Persists across reboots. Verify: launchctl getenv NPM_CONFIG_ALLOW_FILE → none

Shell profile (.zshrc / .bashrc) — covers new terminal sessions:

Terminal window
# BEGIN npm-security-env
export NPM_CONFIG_ALLOW_FILE=none
export NPM_CONFIG_ALLOW_REMOTE=none
export NPM_CONFIG_ALLOW_DIRECTORY=none
export NPM_CONFIG_ALLOW_GIT=false
export NPM_CONFIG_IGNORE_SCRIPTS=true
export NPM_CONFIG_MIN_RELEASE_AGE=7
# END npm-security-env

Linux — /etc/profile.d/ (system-wide):

Terminal window
sudo tee /etc/profile.d/npm-security.sh <<'EOF'
export NPM_CONFIG_ALLOW_FILE=none
export NPM_CONFIG_ALLOW_REMOTE=none
export NPM_CONFIG_ALLOW_DIRECTORY=none
export NPM_CONFIG_ALLOW_GIT=false
export NPM_CONFIG_IGNORE_SCRIPTS=true
export NPM_CONFIG_MIN_RELEASE_AGE=7
EOF

If you’re running escrow, escrow-cli automates all of this in two commands:

Terminal window
escrow-cli config write-env # LaunchAgent (macOS) or /etc/profile.d/ (Linux)
escrow-cli config write-shell # .zshrc + .bashrc

3. Real-time install verification

The .npmrc flags are static gates. These wrapper tools add real-time threat intelligence at install time:

ToolByInstallAge gateEcosystems
safe-chainAikidocurl -fsSL …install-safe-chain.sh | sh✓ 48 h defaultnpm/pnpm/yarn/bun + pip/uv/poetry/pdm
sfwSocketnpm i -g sfw✗npm/yarn/pnpm/pip/uv/cargo
scfwDatadogpipx 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

EcosystemRuns code on installNative age gateScript 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

EcosystemToolConfig fileMin age (7 days)Block scripts
Node.jsnpm >= 11.15.npmrcmin-release-age=7ignore-scripts=true
Node.jspnpm v10 >= 10.16.npmrcminimumReleaseAge=10080ignore-scripts=true
Node.jspnpm v11.npmrc + pnpm-workspace.yamlminimumReleaseAge=10080allowBuilds: {}
Node.jsyarn >= 4.10.yarnrc.ymlnpmMinimalAgeGate: 10080enableScripts: false
Node.jsbun >= 1.3bunfig.tomlminimumReleaseAge = 10080ignore-scripts = true
Pythonuvuv.tomlexclude-newer = "P7D"only-binary = [":all:"]
Pythonpippip.confnone — use proxyonly-binary = :all:
Gogoenvnone — use proxyno install scripts
Rustcargodeny.tomlnone — use proxyno off switch
JavaMavensettings.xmlnone — use proxydeclared in pom.xml
JavaGradlegradle.propertiesnone — use proxydeclared in build.gradle
.NETNuGetnuget.confignone — use proxyno install scripts
PHPComposercomposer.jsonnone — use proxy--no-scripts

Units: npm uses days. pnpm, yarn, and bun use minutes (10080 min = 7 days).

Per-ecosystem config

npm (>= 11.15)

.npmrc
min-release-age=7
ignore-scripts=true
allow-git=false
allow-file=none
allow-remote=none
allow-directory=none
registry=https://registry.npmjs.org/

CI: npm ci

pnpm v10

.npmrc
minimumReleaseAge=10080
ignore-scripts=true
blockExoticSubdeps=true
registry=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 ONLY
minimumReleaseAge=10080
registry=https://registry.npmjs.org/
# pnpm-workspace.yaml — build policy lives here
allowBuilds: {}
strictDepBuilds: true
blockExoticSubdeps: true

CI: pnpm install --frozen-lockfile

Yarn Berry (>= 4.10)

.yarnrc.yml
npmMinimalAgeGate: 10080
enableScripts: false
enableHardenedMode: true
checksumBehavior: throw
nodeLinker: node-modules
npmRegistryServer: "https://registry.npmjs.org"

CI: yarn install --immutable

Bun (>= 1.3)

bunfig.toml
[install]
minimumReleaseAge = 10080
ignore-scripts = true
registry = "https://registry.npmjs.org/"

CI: bun install --frozen-lockfile

uv (Python)

uv.toml
exclude-newer = "P7D"
[pip]
require-hashes = true
only-binary = [":all:"]

CI: uv sync --frozen

pip

# pip.conf
[global]
require-hashes = true
only-binary = :all:
index-url = https://pypi.org/simple/

No native age gate — use escrow or another proxy.

Go

Terminal window
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

gradle.properties
org.gradle.dependency.verification=strict

CI: ./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 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.

7888/dashboard
# Homebrew (macOS — auto-starts as a service)
brew tap jverhoeks/tap && brew install escrow
brew services start escrow
# Docker
docker run -p 7888:7888 ghcr.io/jverhoeks/escrow:latest
escrow.toml
[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:

Terminal window
escrow-cli config write # writes ~/.npmrc, ~/.cargo/config.toml, etc.
escrow-cli config write-env # LaunchAgent / profile.d — GUI apps
escrow-cli config write-shell # .zshrc + .bashrc

Point 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 Curationescrow
Age gate✓ configurable✓ configurable
Ecosystemsnpm, PyPI, Maven, Go (Cargo: partial)npm, PyPI, Go, Cargo, NuGet, Maven, Composer
OSV / malware✓ via Xray✓
Block behavioursubstitutes older versionblocks + dashboard
Cost💰 paid add-onFree / 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).

Terminal window
docker compose up -d nexus && bash nexus/setup.sh
ToolNexus URL
npm / pnpm / bunhttp://localhost:8081/repository/npm-proxy/
pip / uvhttp://localhost:8081/repository/pypi-proxy/simple/
Gohttp://localhost:8081/repository/go-proxy,off
Mavenhttp://localhost:8081/repository/maven-central/
NuGethttp://localhost:8081/repository/nuget-proxy/index.json
Composerhttp://localhost:8081/repository/composer-proxy/

Nexus uses ~1.5 GB RAM at rest. Nexus CE 3.70+ requires EULA acceptance — run setup.sh or POST /service/rest/v1/system/eula {"accepted":true}.

Per-ecosystem lightweight alternatives

For single-ecosystem needs or the lowest possible footprint:

ProxyEcosystemAge enforcement
Verdaccionpm / pnpm / yarn / bunServer-side
devpipip / uvClient-side only
AthensGo modulesClient-side only
ReposiliteMaven / GradleClient-side only
BaGetterNuGetClient-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

References

All posts