Skip to content

Repository files navigation

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

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 on this machine

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 none

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. Persist flags across your whole environment

.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 ~/.npmrc at 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=7

The easy button — escrow-cli

If 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-shell

write-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.

Method 2 — Shell profile (.zshrc / .bashrc)

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-env

Activate without opening a new terminal: source ~/.zshrc

Method 3 — macOS LaunchAgent (recommended — covers GUI apps)

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 &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>
  </array>
</dict>
</plist>

Load it (takes effect immediately for new processes, persists across reboots):

launchctl load ~/Library/LaunchAgents/com.npm-security.environment.plist

Verify: 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.plist

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

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

Takes effect on next login, or immediately: source /etc/profile.d/npm-security.sh


3. Real-time install-time verification

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      # Datadog

safe-chain is the only wrapper that also enforces a release-age gate, making it the closest complement to the .npmrc flags 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 — .npmrc flags 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.


Layer 2 — Harden your projects

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 two controls that matter most

1. Minimum release age (quarantine period)

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.

2. Block install scripts (postinstall RCE prevention)

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.


What each ecosystem can and cannot enforce

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.


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 — 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).


Per-ecosystem config

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.

npm (>= 11.15)

# .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-scripts does not block git dependencies. npm calls the system git binary directly to fetch git-hosted packages — this happens outside the lifecycle hook system, so ignore-scripts=true has no effect on it. Worse, a malicious package can include its own .npmrc that overrides which binary npm treats as git, turning a git dependency install into arbitrary code execution. allow-git=false shuts 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 to none restricts every install to the configured registry. This is safe for the vast majority of projects; if you have a legitimate file: or https:// dependency, override per-project. In npm 12, allow-git will change its default from all to none — 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


pnpm v10 (>= 10.16)

# .npmrc
minimumReleaseAge=10080   # minutes = 7 days
ignore-scripts=true
blockExoticSubdeps=true
registry=https://registry.npmjs.org/

CI command: pnpm install --frozen-lockfile


pnpm v11 — breaking change

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: true

To allow a specific package (e.g. esbuild's native binary fetcher):

allowBuilds:
  esbuild: true

CI command: pnpm install --frozen-lockfile


Yarn Berry (>= 4.10)

# .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


Bun (>= 1.3)

# 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 (Python)

# 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.py is 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

# 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

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=...,direct is a trap. If the proxy is unavailable, direct silently downloads from the original VCS. That bypasses everything. Use off so the build fails loudly instead.


Rust / Cargo

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", and deny = [...] fields were removed. Upgrade your deny.toml or cargo deny check will fail silently.


Java / Maven

<!-- 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

checksumPolicy defaults to warn. Most Maven setups never set this explicitly. A tampered artifact produces a warning in the build log and then installs. Set it to fail.

Plugins are not the same threat as npm postinstall. Maven plugins run during mvn install, but only those you explicitly declare in pom.xml. A transitive dependency cannot silently inject a plugin into your build — unlike npm's postinstall, which can be added by any package in your dependency tree without your knowledge.


Java / Gradle

// 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=strict

Key CI commands:

./gradlew dependencies --write-locks          # generate lock files
./gradlew build --dependency-verification=strict
./gradlew dependencyCheckAnalyze              # OWASP scan

Dependency 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.xml to be committed.


.NET / NuGet

<!-- 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-transitive

Central 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.


PHP / Composer

// 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


Layer 3 — Org-wide enforcement

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 — recommended (7 ecosystems, age + OSV enforcement)

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 && ./escrow

Policy 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 status

Point each tool at escrow (default port 7888):

npm / pnpm / bun.npmrc:

registry=http://localhost:7888/

uv / pipuv.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/"

NuGetnuget.config:

<packageSources>
  <clear />
  <add key="escrow" value="http://localhost:7888/nuget/index.json"
       allowInsecureConnections="true" />
</packageSources>

Mavensettings.xml:

<mirror>
  <id>escrow</id>
  <mirrorOf>*</mirrorOf>
  <url>http://localhost:7888/maven2</url>
</mirror>

Composercomposer.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/escrow

Nexus — if you already run it

Nexus 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.sh

setup.sh is idempotent — safe to re-run if interrupted.

Proxy URLs

Point each tool at the matching Nexus repository:

Tool Setting Value
npm / pnpm / bun .npmrcregistry http://localhost:8081/repository/npm-proxy/
yarn .yarnrc.ymlnpmRegistryServer http://localhost:8081/repository/npm-proxy/
pip pip.confindex-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.jsonrepositories[0].url http://localhost:8081/repository/composer-proxy/

What Nexus does not protect

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/eula with {"accepted":true}.


JFrog Curation — commercial, if you're on the JFrog platform

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.


Per-ecosystem lightweight alternatives

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 -d

Point each tool at the local port:

npm / pnpm / bun.npmrc:

registry=http://localhost:4873/

yarn.yarnrc.yml:

npmRegistryServer: "http://localhost:4873"

uv / pipuv.toml:

[pip]
index-url = "http://localhost:3141/root/pypi/+simple/"

Go:

export GOPROXY="http://localhost:3000,off"

Mavensettings.xml:

<mirror>
  <id>local-reposilite</id>
  <mirrorOf>*</mirrorOf>
  <url>http://localhost:8080/central</url>
  <checksumPolicy>fail</checksumPolicy>
</mirror>

NuGetnuget.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.

Testing proxy isolation

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)

Verify all three layers work

Config typos are silent. Run these tests to verify the settings actually do what they claim.

Test 1: install script blocking

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, uv

Test 2: minimum release age (npm / pnpm / yarn / uv)

Uses 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)

Test 3: private proxy isolation

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)

Test 4: Go controls

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.sh

Test 5: Rust controls

Asserts 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)

Test 6: Java controls

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: mvn

Test 7: .NET controls

Asserts dotnet restore works, locked mode passes, Central Package Management is configured, and NuGetAudit is enabled.

bash tests/test-dotnet.sh
# Requires: dotnet

Test 8: escrow proxy integration (npm, PyPI, Go, Cargo, NuGet, Maven, Composer)

Builds 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/escrow

Things Most Teams Miss

Maven 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.


What This Does Not Cover

  • 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 lodash1odash (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.

References

About

Defend every ecosystem against supply chain attacks: exact configs for npm, PyPI, Go, Maven, NuGet, Cargo + Nexus proxy + shell tests

Resources

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages