Most organisations install packages from dozens of places: every laptop, every runner, every Dockerfile, each talking straight to a public registry. That makes policy impossible, because there is nowhere to put it.
The fix is a single gate. Every install goes through it, and the gate applies a few simple rules. This guide covers the four that do most of the work.
Step 1: one registry proxy
Run a caching proxy for each ecosystem you use, and point every client at it. Several open-source and self-hosted repository managers can do this. The proxy fetches from the public registry on first request, caches the result and serves your internal packages from the same address.
# .npmrc (repo root, committed)
registry=https://packages.internal.example/npm/
# pip.conf
[global]
index-url = https://packages.internal.example/pypi/simple
Then make it the only route. Block direct egress to public registries from CI runners, so a forgotten Dockerfile fails loudly instead of quietly bypassing the gate. On laptops, ship the config through your device management or a bootstrap script.
The proxy also gives you something you rarely have today: a log of every package and version your organisation actually pulls. That log is what turns the next public advisory from a scramble into a search.
Step 2: make new versions wait
Malicious versions tend to be noticed and pulled quickly. A cooldown, sometimes called a minimum release age, means a version must have been public for a few days before your builds will take it. You lose almost nothing, because very few fixes are urgent enough that a few days matter, and those can be allowed by hand.
You can apply it at the proxy, in the package manager or in the update bot:
# pnpm-workspace.yaml (value in minutes: 3 days)
minimumReleaseAge: 4320
# .github/dependabot.yml
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 5
Renovate has an equivalent minimumReleaseAge setting. Pick one place to enforce it and document the exception process, so people do not route around it when they are in a hurry.
Step 3: block install scripts
Install-time scripts are where most malicious packages do their work. Turn them off by default and allow the short list of packages that genuinely compile something.
# npm: .npmrc
ignore-scripts=true
# pnpm: package.json (only these may run build scripts)
{
"pnpm": {
"onlyBuiltDependencies": ["esbuild", "sharp"]
}
}
Recent pnpm versions already refuse dependency build scripts unless they are allow-listed. With npm, ignore-scripts also stops your own project's scripts running on install, so test it on one repository first and run the few builds you need explicitly. For Python, prefer wheels over source distributions where you can; pip install --only-binary :all: refuses anything that would run setup.py.
Step 4: the lockfile decides
A gate is only useful if the resolver cannot quietly pick something new behind it. Commit lockfiles, and use the install commands that refuse to change them:
npm ci # fails if package-lock.json is out of sync
pnpm install --frozen-lockfile
pip install --require-hashes -r requirements.txt
Hash-pinned requirements mean that even if a version is replaced on the registry, the install fails instead of accepting different bytes. Treat lockfile changes as code: they should appear in a pull request, and large unexplained diffs deserve a question.
Rolling it out
Do it in this order, because each step makes the next one safer:
- Stand up the proxy and point CI at it. Watch the logs for a week to learn what you actually pull.
- Point laptops at it, then block direct registry egress from runners.
- Add the cooldown at one layer, with a written way to request an exception.
- Switch off install scripts in one repository, fix what breaks, then make it the default.
- Move every pipeline to frozen or hashed installs.
The result is boring in the best way. New packages still arrive, but through one door, a few days late, without running code on the way in and only in the exact versions someone reviewed.
What to do on Monday
- Grep your Dockerfiles and CI config for public registry URLs and
npm installwithoutci. - Check whether any repository has no lockfile at all.
- List the packages in your main app that declare install scripts, and decide which ones really need them.
- Pick the owner for the proxy. A gate nobody owns becomes a gate everyone bypasses.
Where this sits on the line
In the tower (in development):