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 guide 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 |
Three changes protect you from the most common attack patterns before you write a single line of project code.
Add these flags to your global ~/.npmrc (protects every project on this machine):
# ~/.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 set them in one shot from the terminal:
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 nonenpm 12 preview:
allow-gitwill change its default fromalltonone. Setting it explicitly now means your config is already forward-compatible.
.npmrc covers CLI invocations in a terminal. 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.
IDE-bundled runtimes bypass
~/.npmrc. VSCode, Cursor, and Zed bundle their own Node.js for extension hosts. That Node is not your system Node; it may not read~/.npmrcat all.launchctl setenv(macOS) or/etc/profile.d/(Linux) are the only ways to guarantee the flags reach every process regardless of how it was launched. See Things Most Teams Miss.
Three methods cover the gaps:
| # | Method | Covers | Platform | Root? |
|---|---|---|---|---|
| 1 | ~/.npmrc |
CLI tools in terminals | All | No |
| 2 | Shell profile (.zshrc / .bashrc) |
New terminal sessions | macOS / Linux | No |
| 3 | macOS LaunchAgent (launchctl setenv) |
All processes — GUI apps, IDE extensions, daemons | macOS | No |
| 4 | /etc/profile.d/npm-security.sh |
All login sessions | Linux | Yes |
npm reads env vars prefixed NPM_CONFIG_ (case-insensitive), so these env vars mirror the .npmrc flags:
NPM_CONFIG_ALLOW_FILE=none
NPM_CONFIG_ALLOW_REMOTE=none
NPM_CONFIG_ALLOW_DIRECTORY=none
NPM_CONFIG_ALLOW_GIT=false
NPM_CONFIG_IGNORE_SCRIPTS=true
NPM_CONFIG_MIN_RELEASE_AGE=7If you are running the escrow proxy (or plan to), escrow-cli automates all four methods — no manual plist editing, no file hunting:
# Write all tool config files (~/.npmrc, ~/.cargo/config.toml, etc.)
escrow-cli config write
# Inject env vars for GUI apps AND terminals:
escrow-cli config write-env # macOS LaunchAgent or Linux /etc/profile.d/ — covers GUI apps
escrow-cli config write-shell # .zshrc + .bashrc — covers new terminal sessions
# Verify everything is active:
escrow-cli config check
escrow-cli config check-env
escrow-cli config check-shellwrite-env generates and loads the LaunchAgent plist automatically (no root needed, SIP-safe). write-shell inserts a marked block into your shell profiles that can be cleanly removed with escrow-cli config restore-shell. You get all four methods in about 30 seconds.
Append to ~/.zshrc and/or ~/.bashrc:
# 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-envActivate without opening a new terminal: source ~/.zshrc
Save to ~/Library/LaunchAgents/com.npm-security.environment.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key> <string>com.npm-security.environment</string>
<key>RunAtLoad</key> <true/>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<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>
</array>
</dict>
</plist>Load it (takes effect immediately for new processes, persists across reboots):
launchctl load ~/Library/LaunchAgents/com.npm-security.environment.plistVerify: launchctl getenv NPM_CONFIG_ALLOW_FILE → should print none
Undo:
launchctl unload ~/Library/LaunchAgents/com.npm-security.environment.plist
rm ~/Library/LaunchAgents/com.npm-security.environment.plistsudo 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
EOFTakes effect on next login, or immediately: source /etc/profile.d/npm-security.sh
The .npmrc flags and env vars are static gates — they enforce policy on every install unconditionally. Wrapper tools add a complementary layer: real-time checking against threat intelligence databases at the moment you run an install.
| Tool | By | Install | Ecosystems | How it intercepts | Age gate | Cost |
|---|---|---|---|---|---|---|
| safe-chain | Aikido | curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh |
npm / npx / pnpm / yarn / bun + pip / uv / poetry / pdm | Local HTTP proxy | ✓ 48 h default (configurable) | Free / OSS |
| sfw | Socket | npm i -g sfw |
npm / yarn / pnpm / pip / uv / cargo | Ephemeral HTTP proxy + Socket API | ✗ | Free (limited policy) |
| scfw | Datadog | pipx install scfw |
npm, pip, poetry | Pre-install wrapper (no proxy) | ✗ | Free / OSS |
All three use the same "prefix your command" model:
safe-chain npm install lodash # Aikido
sfw npm install lodash # Socket
scfw run npm install lodash # Datadogsafe-chain is the only wrapper that also enforces a release-age gate, making it the closest complement to the
.npmrcflags above. sfw and scfw check against known-malware databases but do not enforce a quarantine window.
These tools are complementary to — not a replacement for —
.npmrcflags and a proxy. The flags and proxy enforce policy unconditionally on every install; wrapper tools add a real-time threat intelligence layer for interactive developer workflows.
Config committed to the repo means every developer, every CI run, and every environment gets the same policy automatically — even before they've set up their machine.
The pattern of most supply chain attacks: compromise a package, publish a malicious version, and hope developers install it before it is pulled. A quarantine window breaks this. If your toolchain refuses packages published in the last 7 days, the attacker has to maintain an undetected compromise for a week — long enough for the community to notice and the registry to yank the version.
This control is available in npm, pnpm, yarn, bun, and uv. Go, Maven, Gradle, NuGet, and Cargo have no native quarantine setting; for those, a proxy (Layer 3) is the equivalent control.
postinstall hooks execute arbitrary code the moment you run an install command. This is the delivery mechanism for most supply chain payloads — the malicious code runs before you even import the package. Disabling install scripts globally, with explicit opt-in for the handful of packages that legitimately need them (native compilers, binary fetchers), eliminates this vector.
This control exists in npm, pnpm, yarn, bun, and uv (only-binary). Go, Maven, Gradle, and NuGet have no install scripts by design. Rust is the exception: build.rs runs arbitrary code at compile time and cannot be globally disabled.
Not every ecosystem supports the same controls. This table shows the ceiling — what is achievable even with perfect configuration.
| Ecosystem | Runs code on install | Release-age gate | Script blocking | Proxy / mirror | Integrity check |
|---|---|---|---|---|---|
| npm / pnpm / yarn / bun | ✓ postinstall | ✓ client-side ✓ server-side (Verdaccio, escrow) |
✓ | ✓ | ✓ |
| Python (pip / uv) | ✓ sdist only | ✓ uv only, client-side ✓ server-side (escrow) |
✓ only-binary |
✓ | ✓ |
| PHP / Composer | ✓ post-install-cmd | — | ✓ --no-scripts |
✓ server-side (escrow) | ✓ |
| Go | — | ✓ server-side (escrow) | n/a | ✓ | ✓ (GOSUMDB) |
| Maven / Gradle | — (build only¹) | ✓ server-side (escrow) | n/a | ✓ | ✓ |
| NuGet | — (build only²) | ✓ server-side (escrow) | n/a | ✓ | ✓ |
| Rust / Cargo | ✓ build.rs | ✓ server-side (escrow) | — (no off switch) | ✓ (escrow v1.5.0) | ✓ cargo-deny + vet |
¹ Maven/Gradle plugins run during mvn install / gradle build but only those explicitly declared in your pom.xml / build.gradle. Transitive dependencies cannot silently inject build code.
² NuGet MSBuild .targets and IL weavers activate at dotnet build, not dotnet restore.
Rust has the weakest client-side controls. build.rs cannot be globally disabled and there is no quarantine window in the Cargo toolchain itself. escrow v1.5.0 adds server-side age enforcement for Cargo — it is the only open-source proxy to do so. Policy tools (cargo-deny, cargo-vet) remain the best defence for teams that cannot run a proxy.
pip has no built-in age gate. The exclude-newer setting exists only in uv. pip users need escrow or another proxy that enforces the quarantine window server-side, or should switch to uv.
PHP/Composer has no native age gate. Use escrow or a private Packagist mirror (Satis) for org-wide enforcement.
Verdaccio and escrow are the only open-source proxies with server-side age enforcement. escrow v1.5.0 covers npm, PyPI, Go modules, Cargo, NuGet, Maven, and Composer in a single binary. Every other proxy enforces the age gate at the client only.
| 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 — see Layer 3) | only-binary = :all: |
| Go | go | GOENV / CI env |
none (use proxy — see Layer 3) | no install scripts by design |
| Rust | cargo | deny.toml + supply-chain/ |
none (use proxy — see Layer 3) | no off switch; use cargo-vet + cargo-deny |
| Java | Maven | pom.xml + settings.xml |
none (use proxy — see Layer 3) | plugins run at build, not install; declared explicitly in pom.xml |
| Java | Gradle | build.gradle.kts + gradle.properties |
none (use proxy — see Layer 3) | plugins run at build, not install; declared explicitly in build.gradle |
| .NET | NuGet | nuget.config + *.csproj |
none (use proxy — see Layer 3) | no install scripts; MSBuild targets run at build; IL weaving is the threat |
| PHP | Composer | composer.json |
none (use proxy — see Layer 3) | --no-scripts flag + allow-plugins: {} |
Units: npm uses days. pnpm, yarn, and bun use minutes (10080 min = 7 days).
Combine these configs with your proxy URL if you are running one (see Layer 3). Without a proxy, these configs work against the public registries directly.
# .npmrc
min-release-age=7 # days — npm uses DAYS, not minutes
ignore-scripts=true
allow-git=false # requires npm >= 11.10; blocks git dependency execution
allow-file=none # requires npm >= 11.15; blocks file: / tarball deps
allow-remote=none # requires npm >= 11.15; blocks https:// / http:// deps
allow-directory=none # requires npm >= 11.15; blocks local directory deps
registry=https://registry.npmjs.org/Unit gotcha: npm uses days. pnpm and yarn use minutes (10080 = 7 days). Easy to mix up.
ignore-scriptsdoes not block git dependencies. npm calls the systemgitbinary directly to fetch git-hosted packages — this happens outside the lifecycle hook system, soignore-scripts=truehas no effect on it. Worse, a malicious package can include its own.npmrcthat overrides which binary npm treats asgit, turning a git dependency install into arbitrary code execution.allow-git=falseshuts this off entirely by blocking all git-protocol dependencies. Requires npm >= 11.10. See I thought ignore-scripts made npm installs safe. It doesn't.
allow-file,allow-remote,allow-directory(npm >= 11.15): Non-registry sources — local tarballs, remote URLs, and local directories — are common vectors for dependency confusion and CI/CD injection attacks. Setting all three tononerestricts every install to the configured registry. This is safe for the vast majority of projects; if you have a legitimatefile:orhttps://dependency, override per-project. In npm 12,allow-gitwill change its default fromalltonone— set it explicitly now to be forward-compatible. Hat tip: Robert Slootjes (AWS Community Builder) for surfacing these flags — cryptika.com
CI command: npm ci
# .npmrc
minimumReleaseAge=10080 # minutes = 7 days
ignore-scripts=true
blockExoticSubdeps=true
registry=https://registry.npmjs.org/CI command: pnpm install --frozen-lockfile
pnpm 11 moved build policy out of .npmrc. If you put allowBuilds or strictDepBuilds in .npmrc, they are silently ignored.
# .npmrc -- auth and registry ONLY in v11
minimumReleaseAge=10080
registry=https://registry.npmjs.org/# pnpm-workspace.yaml -- build policy lives here
minimumReleaseAge: 10080 # default in v11 is 1440 (1 day); set explicitly for clarity
allowBuilds: {} # empty = no package may run build scripts
strictDepBuilds: true # unlisted packages are an error, not a warning
blockExoticSubdeps: trueTo allow a specific package (e.g. esbuild's native binary fetcher):
allowBuilds:
esbuild: trueCI command: pnpm install --frozen-lockfile
# .yarnrc.yml
npmMinimalAgeGate: 10080 # minutes = 7 days; requires yarn >= 4.10
enableScripts: false # blocks postinstall globally
enableHardenedMode: true # validates yarn.lock against registry at install time
checksumBehavior: throw
nodeLinker: node-modules
npmRegistryServer: "https://registry.npmjs.org"To allow scripts for a specific package:
"dependenciesMeta": {
"esbuild": { "built": true }
}CI command: yarn install --immutable
# bunfig.toml
[install]
minimumReleaseAge = 10080 # minutes = 7 days
ignore-scripts = true
registry = "https://registry.npmjs.org/"To allow scripts for a specific package: add "trustedDependencies": ["esbuild"] in package.json.
CI command: bun install --frozen-lockfile
# uv.toml
exclude-newer = "P7D" # ISO 8601 duration; also accepts "7 days" or an RFC 3339 timestamp
[pip]
require-hashes = true
only-binary = [":all:"] # never run setup.py; blocks install-time RCE entirely
setup.pyis RCE. Installing a source distribution runs arbitrary Python at install time.only-binary = [":all:"]is the only complete mitigation.
CI command: uv sync --frozen
# pip.conf (~/.config/pip/pip.conf or $PIP_CONFIG_FILE)
[global]
require-hashes = true
only-binary = :all:
index-url = https://pypi.org/simple/pip has no native quarantine setting. Use escrow or another proxy that enforces release-age policy server-side, or switch to uv.
Go has no native minimum release age setting, but it has strong integrity controls built in.
# Source this in CI or run: go env -w GOFLAGS="-mod=readonly"
export GOFLAGS="-mod=readonly"
# Use 'off' not 'direct' as the fallback.
# 'direct' falls back to VCS source if the proxy is unreachable, bypassing all proxy controls.
# 'off' fails the build instead.
export GOPROXY="https://proxy.golang.org,off"
# Checksum database: every download is verified against this transparent log.
# Never set to "off" in production.
export GOSUMDB="sum.golang.org"
# For private modules that should not go through the public proxy:
# export GOPRIVATE="github.com/myorg/*"Key CI commands:
go mod verify # verify all cached modules match go.sum
GOFLAGS=-mod=readonly go build ./... # fail if go.sum is incomplete
govulncheck ./... # vulnerability scan
GOPROXY=...,directis a trap. If the proxy is unavailable,directsilently downloads from the original VCS. That bypasses everything. Useoffso the build fails loudly instead.
Cargo has no ignore-scripts equivalent. build.rs files run with full filesystem and network access and cannot be globally disabled. The mitigations are policy tools.
# deny.toml (cargo-deny >= 0.16 schema)
[advisories]
db-urls = ["https://github.com/rustsec/advisory-db"]
ignore = [] # document suppressions with a reason
[licenses]
# Only listed licenses are allowed. Copyleft is blocked by omission.
allow = ["MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause", "BSD-3-Clause", "ISC"]
confidence-threshold = 0.8
[bans]
multiple-versions = "warn"
[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]# supply-chain/config.toml (cargo-vet)
imports = [
{ name = "mozilla", url = "https://raw.githubusercontent.com/mozilla/cargo-vet/main/supply-chain/audits.toml" },
{ name = "google", url = "https://raw.githubusercontent.com/google/cargo-vet/main/supply-chain/audits.toml" },
]Key CI commands:
cargo build --locked # fail if Cargo.lock is outdated
cargo audit # vulnerability scan (RustSec)
cargo deny check advisories bans sources # policy check
cargo vet # audit check (requires supply-chain/audits.toml)cargo-deny schema changed in 0.16. The old
vulnerability = "deny",unlicensed = "deny", anddeny = [...]fields were removed. Upgrade your deny.toml orcargo deny checkwill fail silently.
<!-- settings.xml mirror block -->
<mirror>
<id>internal-mirror</id>
<mirrorOf>*</mirrorOf>
<url>https://nexus.internal.company.com/repository/maven-public/</url>
<!-- Default checksumPolicy is 'warn' - tampered artifacts pass silently.
'fail' breaks the build on any mismatch. -->
<checksumPolicy>fail</checksumPolicy>
<releases>
<!-- 'never' prevents Maven from re-checking for updated release artifacts,
closing a SNAPSHOT-style poisoning window. -->
<updatePolicy>never</updatePolicy>
</releases>
</mirror><!-- pom.xml - enforcer + extra-enforcer-rules -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.4.1</version>
<dependencies>
<!-- banDuplicateClasses is not built in; requires extra-enforcer-rules -->
<dependency>
<groupId>org.codehaus.mojo</groupId>
<artifactId>extra-enforcer-rules</artifactId>
<version>1.8.0</version>
</dependency>
</dependencies>
<executions>
<execution>
<goals><goal>enforce</goal></goals>
<configuration>
<rules>
<banDuplicateClasses><findAllDuplicates>true</findAllDuplicates></banDuplicateClasses>
<requireMavenVersion><version>[3.8.0,)</version></requireMavenVersion>
</rules>
</configuration>
</execution>
</executions>
</plugin>Key CI commands:
mvn validate # runs enforcer
mvn dependency-check:check # OWASP vulnerability scan
checksumPolicydefaults towarn. Most Maven setups never set this explicitly. A tampered artifact produces a warning in the build log and then installs. Set it tofail.
Plugins are not the same threat as npm postinstall. Maven plugins run during
mvn install, but only those you explicitly declare inpom.xml. A transitive dependency cannot silently inject a plugin into your build — unlike npm'spostinstall, which can be added by any package in your dependency tree without your knowledge.
// build.gradle.kts
dependencyLocking {
lockAllConfigurations()
lockMode.set(LockMode.STRICT) // fail if lock file is missing or outdated
}# gradle.properties
# Verifies actual JAR content hashes, not just version pins.
# Generate the metadata file first: ./gradlew --write-verification-metadata sha256 dependencies
# Then commit gradle/verification-metadata.xml
org.gradle.dependency.verification=strictKey CI commands:
./gradlew dependencies --write-locks # generate lock files
./gradlew build --dependency-verification=strict
./gradlew dependencyCheckAnalyze # OWASP scanDependency locking and dependency verification are different things. Locking pins versions. Verification checks that the downloaded JAR matches a known SHA256. Both are needed. Verification requires
gradle/verification-metadata.xmlto be committed.
<!-- SupplyChainTest.csproj -->
<PropertyGroup>
<RestoreLockedMode>true</RestoreLockedMode>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<NuGetAudit>true</NuGetAudit>
<NuGetAuditMode>all</NuGetAuditMode>
<NuGetAuditLevel>low</NuGetAuditLevel>
</PropertyGroup><!-- Directory.Packages.props - Central Package Management -->
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<!-- Declare all versions here once. csproj files use PackageReference with no Version. -->
<!-- <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" /> -->
</ItemGroup>
</Project>Key CI commands:
dotnet restore /p:RestoreLockedMode=true # fail if packages.lock.json is stale
dotnet list package --vulnerable --include-transitiveCentral Package Management prevents version drift. Without it, individual csproj files can use version ranges like
>= 1.0, which can silently upgrade to a malicious version. With CPM, all versions are declared once and enforced repo-wide.
// composer.json
{
"config": {
"preferred-install": "dist",
"allow-plugins": {}
}
}allow-plugins: {} (empty map) prevents all Composer plugins from activating — this is a separate code-execution vector from scripts.
preferred-install: dist downloads release archives instead of VCS clones, avoiding .git hook exposure.
Always run with --no-scripts in CI:
composer install --no-scripts --prefer-dist --no-interaction--no-scripts prevents all scripts in the scripts block of any package's composer.json from running, including post-install-cmd, pre-install-cmd, and post-update-cmd. Re-enable per-invocation only when you have reviewed the scripts involved.
To point Composer at a private mirror (Satis or Nexus):
{
"repositories": [
{"type": "composer", "url": "https://packagist.internal.company.com"},
{"packagist.org": false}
]
}{"packagist.org": false} disables the public Packagist fallback, preventing packages not in your mirror from being installed — the Composer equivalent of GOPROXY=...,off.
No native age gate. Composer has no minimum release age setting. Use escrow (see Layer 3) or a private Packagist mirror (Satis) to enforce a quarantine window.
CI command: composer install --no-scripts --prefer-dist --no-interaction
Client-side flags cover 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 in the path.
escrow v1.5.0 is a purpose-built supply-chain proxy that enforces age gates, OSV vulnerability checks, and publisher-account-age policy server-side — blocking packages before they reach the package manager. It covers seven ecosystems (npm, PyPI, Go, Cargo, NuGet, Maven, Composer) in one binary and is the only open-source proxy with server-side age enforcement for Cargo, NuGet, and Maven. The proxy ships with a real-time operator dashboard and escrow-cli, a companion tool for routing your development environment through the proxy.
# Homebrew (macOS — recommended, auto-starts as a service)
brew tap jverhoeks/tap
brew install escrow
brew services start escrow
# → http://localhost:7888/dashboard
# Docker
docker run -p 7888:7888 ghcr.io/jverhoeks/escrow:latest
# Build from source
cd /path/to/escrow && go build -o escrow ./cmd/escrow && ./escrowPolicy in escrow.toml:
[server]
host = "127.0.0.1"
port = 7888
[ecosystems]
npm = true
pypi = true
go = true
cargo = true
nuget = true
maven = true
composer = true
[policy.age]
min_days = 7 # block packages published fewer than 7 days ago
action = "block"Quick routing setup with escrow-cli:
# Configure all tools to use escrow (writes ~/.npmrc, ~/.cargo/config.toml, etc.)
escrow-cli config write
# Inject env vars so GUI apps (VSCode, Zed) also use the proxy:
escrow-cli config write-env # macOS LaunchAgent / Linux profile.d
escrow-cli config write-shell # .zshrc + .bashrc
# Check status:
escrow-cli statusPoint each tool at escrow (default port 7888):
npm / pnpm / bun — .npmrc:
registry=http://localhost:7888/uv / pip — uv.toml:
[pip]
index-url = "http://localhost:7888/pypi/simple/"Go:
export GOPROXY="http://localhost:7888/go,off"Cargo — .cargo/config.toml:
[source.crates-io]
replace-with = "escrow"
[source.escrow]
registry = "sparse+http://localhost:7888/cargo/"NuGet — nuget.config:
<packageSources>
<clear />
<add key="escrow" value="http://localhost:7888/nuget/index.json"
allowInsecureConnections="true" />
</packageSources>Maven — settings.xml:
<mirror>
<id>escrow</id>
<mirrorOf>*</mirrorOf>
<url>http://localhost:7888/maven2</url>
</mirror>Composer — composer.json:
{
"repositories": [
{ "type": "composer", "url": "http://localhost:7888/composer" }
],
"config": { "secure-http": false }
}Test results (v1.5.0, block-all and allow-all modes):
npm block-all: lodash manifest versions blocked PASS
PyPI block-all: requests releases blocked PASS
uv block-all: requests blocked by escrow age gate PASS
Go block-all: module blocked (403) PASS
NuGet block-all: newtonsoft.json versions blocked (0 versions) PASS
Maven block-all: commons-lang3 metadata returned valid XML PASS
Composer block-all: vendor/package versions blocked (0 versions) PASS
npm allow-all: once installed via escrow PASS
PyPI allow-all: requests proxied through escrow PASS
uv allow-all: Simple API links with data-upload-time present PASS
Go allow-all: module proxied successfully PASS
Cargo allow-all: serde index accessible, dl URL points at escrow PASS
NuGet allow-all: newtonsoft.json versions proxied, URLs rewritten PASS
Maven allow-all: commons-lang3 metadata + POM download proxied PASS
Composer allow-all: vendor/package proxied, dist URLs rewritten PASS
healthz: status=ok, 7 upstreams checked PASS
bash tests/test-escrow-full.sh
# Auto-discovers ../2026-05-16-escrow or set ESCROW_DIR=/path/to/escrowNexus is the right choice when your org already operates it, when you need to support package formats escrow doesn't cover, or when your team is more familiar with Sonatype's tooling. Unlike escrow, Nexus does not enforce a release-age gate server-side — the quarantine window stays client-side via the per-ecosystem configs in Layer 2. Nexus also does not cover Cargo (that is a Nexus Pro feature only).
Nexus Repository OSS is free, Apache-licensed, and proxies npm, PyPI, Go modules, Maven/Gradle, NuGet, PHP/Composer, and a dozen other formats from a single container.
# Start Nexus alongside the other proxies
docker compose up -d nexus
# First-time setup: creates npm, PyPI, Go, Maven, NuGet, Composer proxy repos (~1-2 min)
bash nexus/setup.shsetup.sh is idempotent — safe to re-run if interrupted.
Point each tool at the matching Nexus repository:
| Tool | Setting | Value |
|---|---|---|
| npm / pnpm / bun | .npmrc → registry |
http://localhost:8081/repository/npm-proxy/ |
| yarn | .yarnrc.yml → npmRegistryServer |
http://localhost:8081/repository/npm-proxy/ |
| pip | pip.conf → index-url |
http://localhost:8081/repository/pypi-proxy/simple/ |
| uv | uv.toml → [pip] index-url |
http://localhost:8081/repository/pypi-proxy/simple/ |
| Go | GOPROXY env |
http://localhost:8081/repository/go-proxy,off |
| Maven | settings.xml mirror <url> |
http://localhost:8081/repository/maven-central/ |
| NuGet | nuget.config source value |
http://localhost:8081/repository/nuget-proxy/index.json |
| Composer | composer.json → repositories[0].url |
http://localhost:8081/repository/composer-proxy/ |
Nexus caches and air-gaps packages, but the release-age gate stays client-side regardless. The ignore-scripts, allowBuilds, and only-binary settings from Layer 2 are still necessary — Nexus cannot replace them.
RAM: Nexus uses ~1.5 GB at rest. If resources are constrained or you want server-side age enforcement, use escrow instead.
Nexus CE 3.70+ requires EULA acceptance before proxying works. If your Go or Maven fetches return 403 immediately after spinning up Nexus, the EULA has not been accepted. Run
setup.sh— it handles this automatically. Doing it manually:POST /service/rest/v1/system/eulawith{"accepted":true}.
JFrog Curation is the commercial answer to the same problem escrow solves. It sits in front of Artifactory's remote repositories and intercepts every package request before it enters your SDLC.
The key feature is the time-delay / immature package policy: it blocks newly published versions until they reach a configurable minimum age (14 days is the cited example). When a developer requests a blocked package, Curation can silently substitute a safe older version instead of hard-blocking — useful for CI pipelines that cannot tolerate install failures, but it reduces visibility compared to escrow's explicit approval dashboard.
| Feature | JFrog Curation | escrow |
|---|---|---|
| Server-side age gate | ✓ configurable (14 d example) | ✓ configurable (min_days) |
| Ecosystems confirmed | npm, PyPI, Maven, Go | npm, PyPI, Go, Cargo, NuGet, Maven, Composer |
| Cargo / Rust | ⚠ "varying levels of support" | ✓ |
| NuGet / Composer | not confirmed | ✓ |
| OSV / malware scanning | ✓ via Xray | ✓ |
| Blocked package behaviour | silently substitutes older version | blocks + dashboard approval |
| Cost | 💰 commercial (paid JFrog Platform add-on) | Free / OSS |
Cargo is the gap to verify. JFrog documentation describes Cargo support as having "varying levels of support" — confirm with JFrog before relying on Curation for Rust supply chain enforcement.
Silent substitution vs explicit approval. Curation's default behaviour on a blocked package is to swap in an older safe version with no developer notification. escrow blocks the install entirely and surfaces it in the dashboard, forcing a conscious decision. Neither is strictly better — pick the model that fits your team's risk tolerance.
If you need a proxy for a single ecosystem or want the lowest possible footprint, these purpose-built proxies each cover one or two ecosystems. Verdaccio is the only one in this list with server-side age enforcement.
| Proxy | Ecosystem | Port | Age enforcement |
|---|---|---|---|
| Verdaccio | npm / pnpm / yarn / bun | 4873 | Server-side (minAgeDays: 7) |
| devpi | pip / uv | 3141 | Client-side only |
| Athens | Go modules | 3000 | Client-side only |
| Reposilite | Maven / Gradle | 8080 | Client-side only |
| BaGetter | NuGet | 5555 | Client-side only |
cd proxies/
docker compose up -dPoint each tool at the local port:
npm / pnpm / bun — .npmrc:
registry=http://localhost:4873/yarn — .yarnrc.yml:
npmRegistryServer: "http://localhost:4873"uv / pip — uv.toml:
[pip]
index-url = "http://localhost:3141/root/pypi/+simple/"Go:
export GOPROXY="http://localhost:3000,off"Maven — settings.xml:
<mirror>
<id>local-reposilite</id>
<mirrorOf>*</mirrorOf>
<url>http://localhost:8080/central</url>
<checksumPolicy>fail</checksumPolicy>
</mirror>NuGet — nuget.config:
<packageSources>
<clear />
<add key="local" value="http://localhost:5555/v3/index.json"
allowInsecureConnections="true" />
</packageSources>Do not add a second index as a fallback. Additional indexes are a dependency confusion risk: the package manager picks the highest version across all sources, so an attacker can register a public package with a higher version than your internal one.
The test spins up a local Verdaccio instance with no upstream (air-gapped), points the package manager at it, and tries to install a well-known public package (lodash). The install must fail, proving the package manager has no public internet fallback. A second assertion publishes an approved package to the proxy and confirms it installs successfully.
bash tests/test-private-proxy.sh
# Requires: npx (for verdaccio)Config typos are silent. Run these tests to verify the settings actually do what they claim.
Creates a package with a postinstall that writes a sentinel file, installs it, asserts the file was not created.
bash tests/run-script-tests.sh
# Covers: npm, pnpm v10, pnpm v11, yarn, bun, uvUses a local Verdaccio registry to publish a test package timestamped right now, asserts the package manager blocks it.
bash tests/test-minimum-age.sh
# Requires: npx (for verdaccio)Runs an air-gapped Verdaccio (no upstream), asserts that public packages cannot be fetched, then asserts that an internally approved package can.
bash tests/test-private-proxy.sh
# Requires: npx (for verdaccio)Asserts that -mod=readonly blocks silent fetches, GOPROXY=off blocks external downloads, and go mod verify passes on a clean module.
bash tests/test-go.shAsserts that cargo deny check passes on the reference config and that the cargo-vet supply-chain files are present.
bash tests/test-cargo.sh
# Requires: cargo-deny (cargo install cargo-deny)Asserts Maven Enforcer passes, checksumPolicy=fail is set, updatePolicy=never is set, and Gradle dependency verification is enabled in strict mode.
bash tests/test-java.sh
# Requires: mvnAsserts dotnet restore works, locked mode passes, Central Package Management is configured, and NuGetAudit is enabled.
bash tests/test-dotnet.sh
# Requires: dotnetBuilds escrow from source, starts it in block-all mode (age = 99999 days), and asserts each ecosystem blocks correctly: npm manifests filtered to zero versions, PyPI releases pruned, Go modules return 403, NuGet version list empty, Maven metadata accessible. Then restarts with no policy and asserts all seven ecosystems proxy through successfully. Skips ecosystems whose CLI is not installed.
bash tests/test-escrow-full.sh
# Auto-discovers ../2026-05-16-escrow or set ESCROW_DIR=/path/to/escrowMaven and Gradle plugins are not the same threat as npm postinstall.
A common assumption is that "Maven plugins run code, so Maven is as dangerous as npm." This is wrong in a meaningful way. npm's postinstall can be injected by any package in node_modules without explicit project consent. Maven and Gradle plugins only execute what you explicitly declare in pom.xml or build.gradle — a transitive dependency cannot silently add a plugin to your build. The threat model is: your build config runs code from plugins; those plugins are fetched from Maven Central. So you still need to vet the plugins you declare and use a proxy with checksumPolicy=fail, but the attack surface is narrower than npm.
Composer scripts run on install exactly like npm postinstall — but fewer teams know this.
composer install executes post-install-cmd and pre-install-cmd scripts from any package's composer.json. This is the same attack surface as npm's postinstall hook. Always run composer install --no-scripts in CI. Unlike npm, there is no persistent config key to disable scripts globally — it must be a flag on every invocation.
ignore-scripts=true does not block git dependencies.
npm calls the system git binary to fetch git-hosted packages, bypassing the lifecycle hook system entirely. A malicious package can include its own .npmrc overriding which binary npm treats as git — turning a git dependency install into arbitrary code execution even with ignore-scripts=true. The fix: add allow-git=false to .npmrc (requires npm >= 11.10). Safety flags only protect the layer they actually control. (source)
pnpm v11 moved build policy to pnpm-workspace.yaml.
Settings in .npmrc are silently ignored. Run pnpm approve-builds to populate the allowlist interactively. If you upgraded from v10 and kept allowBuilds in .npmrc, your build policy is doing nothing.
npm uses days, not minutes.
min-release-age=7 in npm means 7 days. minimumReleaseAge=7 in pnpm means 7 minutes. The config key and unit are both different. Always double-check.
GOPROXY=...,direct is not a safe default.
The official Go docs show direct as the fallback, so most teams leave it. But direct means Go silently downloads from the original VCS when the proxy is unreachable. Use off so you find out about proxy issues rather than bypassing them.
Maven checksumPolicy defaults to warn.
Maven validates checksums by default, but only warns on mismatch. The build continues. A tampered artifact gets installed with a warning in the log that most people never read. Set checksumPolicy=fail.
Gradle dependency locking is not the same as dependency verification. Locking pins which version is resolved. Verification checks that the downloaded JAR matches a known SHA256. You can have locking without verification, which means you know you got version 1.2.3 but not whether the JAR for 1.2.3 has been tampered with. Both are needed.
cargo-deny's schema changed in 0.16.
vulnerability = "deny", unmaintained = "warn", unlicensed = "deny", and the deny = [...] list in [licenses] were all removed. If you have an old deny.toml, running cargo deny check will fail with deprecation errors. The new schema uses allow = [...] in licenses (anything not listed is rejected) and a plain ignore = [] in advisories.
--extra-index-url in pip is a dependency confusion vector.
pip checks all indexes and picks the highest version across all of them. Attackers register public packages with higher versions than your internal ones. Use --index-url (replace, not add) or route everything through a single internal proxy.
NuGet RestoreLockedMode has no effect without packages.lock.json.
The setting is present but harmless until you run dotnet restore once to generate the lock file and commit it. Without the committed lock file, locked mode always succeeds because there is nothing to check against.
Cargo build.rs has no global off switch.
Rust build scripts run with full filesystem and network access during compilation. cargo vet and cargo deny are the only mitigations short of sandboxed builds. Both should be in CI.
A proxy does not replace client-side script blocking.
escrow, Nexus, Verdaccio, and every other proxy cache and air-gap packages. None of them strip postinstall hooks or enforce only-binary before handing a tarball to your package manager. The ignore-scripts, allowBuilds, and only-binary settings from Layer 2 must still be in every project's config even when a proxy is in the path.
.npmrc flags don't reach IDE-bundled runtimes.
VSCode, Cursor, and Zed launch from launchd — your shell profile is never sourced. These apps bundle their own Node.js for extension hosts, which may not read ~/.npmrc at all. The fix: launchctl setenv via a user LaunchAgent (macOS) or /etc/profile.d/ (Linux) injects NPM_CONFIG_* env vars into the OS launch environment so every process inherits them regardless of how it was started. See Layer 1 §2: Persist flags across your whole environment.
- Dependency confusion attacks: An attacker registers a public package with the same name as your internal package at a higher version. The package manager silently installs the public one. Mitigation: scoped packages (
@myorg/), single index only (never--extra-index-url), and a private proxy as the sole source. See the "Do not add a second index as a fallback" note in Layer 3. - Typosquatting: Attackers register
lodash→1odash(one, not L). The quarantine window reduces the risk window but does not eliminate it. Lockfiles are the main mitigation — pin exact versions and commit the lockfile. - SBOM generation:
mvn cyclonedx:makeAggregateBom,./gradlew cyclonedxBom,cargo cyclonedx,dotnet sbom-tool. Generates a software bill of materials for auditing and compliance. - Lockfile integrity in CI: Use
npm ci,pnpm install --frozen-lockfile,uv sync --frozen,cargo build --locked,dotnet restore /p:RestoreLockedMode=true. Never use the plain install command in a pipeline. - Vulnerability scanning:
npm audit,pip-audit,cargo audit,govulncheck,mvn dependency-check:check. - SLSA and Sigstore: Provenance attestation for verifying that a package was built from the claimed source. See
cross-ecosystem/slsa-sigstore.md.
- I thought ignore-scripts made npm installs safe. It doesn't. — The
allow-git=falsebypass and why safety flags only protect the layer they actually control. - Robert Slootjes (AWS Community Builder) — GitHub adds staged publishing to npm to block automated supply chain attacks — source for the npm 11.15
allow-file,allow-remote,allow-directoryflags.