Safeguard
Software Supply Chain Security

Your Internal Package Registry Is the Only Control That Runs Before the Code Does

Most companies run one and treat it as a cache. It is the one place in the build that can refuse a package before an install script executes, which is the only point where prevention is still possible.

Sofia Marchetti
Open Source Program Lead
6 min read

Most companies run an internal package registry and treat it as a cache. It sits in front of npm or PyPI or Maven Central, it makes builds faster and survives an upstream outage, and nobody thinks about it again.

It is also the only place in your build where you can refuse a package before it executes. Every other control you have runs after the dependency is already on disk, and for a malicious package with an install script, that is already too late.

This post is what an internal registry can enforce that a scanner cannot, and the specific configurations that matter. For whoever runs the build platform.

Why the position matters

A scanner reads a lockfile or an installed tree and tells you what is there. By then, for an ecosystem with lifecycle scripts, arbitrary code has already run on the machine that installed it: npm postinstall, Python setup.py, a Gradle plugin. The credentials on that build agent are already reachable.

A registry sits earlier. It answers the request for the package, so it can decline. That is a different kind of control, and it is the only one in the chain positioned to prevent rather than report.

The trade is that it must decide fast, on limited information, without breaking legitimate builds. Which shapes what it should enforce.

What to enforce there

Block your own namespaces from resolving upstream. This is the dependency confusion defence and it is the highest-value setting on this list.

If your internal packages are named @yourco/thing, configure the registry so that scope resolves only to your internal repository, never to the public upstream, even on a miss. The attack is that somebody publishes @yourco/thing publicly with a higher version number and your build prefers it. A registry that falls through to upstream on a miss is the vulnerability; one that returns a hard 404 for your own namespace is immune.

Check this by asking for a package in your namespace that does not exist. If you get anything other than a 404, you have the problem.

curl -s -o /dev/null -w "%{http_code}\n" \
  https://registry.internal/@yourco/definitely-not-a-real-package
# want: 404. Anything else means it is reaching upstream.

Quarantine new releases. A large share of malicious packages are detected and removed within hours to days of publication. A registry that refuses to serve any version published less than, say, 48 hours ago converts most of that window into someone else's problem.

The cost is real: you cannot immediately consume a fix published an hour ago, so you need a documented override path. That override should be a deliberate request, not a flag anyone sets.

Block on known-malicious, not on vulnerable. Worth separating. A package flagged as malware should not resolve, full stop. A package with a known CVE usually should resolve, because blocking it breaks builds that may have a legitimate reason and the right conversation is a triage one, not a hard failure at install time.

Conflating the two is how registries acquire a reputation for breaking things, after which people route around them, and a bypassed registry enforces nothing.

Pin and verify integrity. Serve exactly the artifact your lockfile hash expects. If your registry rewrites, recompresses, or republishes artifacts, integrity hashes can break or, worse, silently stop being checked.

Log everything requested. The full record of what was requested, by whom, and whether it was served. During an incident this is how you answer whether anyone pulled the malicious version, and it is the question you will be asked first. It is also the only reliable inventory of what your builds actually consume, as opposed to what your manifests claim.

The configuration that quietly breaks it

A fallthrough on miss. Covered above, and it is the default in several products because it is convenient.

Developers who can reach upstream directly. If a laptop or a build agent can talk to the public registry without going through yours, the control is advisory. Enforce it at the network layer, not by setting a registry URL in a config file that anyone can change.

A second source in the lockfile. Some lockfiles record the resolved URL per package. If half the entries point at the public registry, those packages are not going through your control regardless of what the configuration says. Grep the lockfile.

Caching a malicious version forever. If a package is pulled from upstream and later removed for being malicious, your cache still has it, and your builds keep succeeding. You need a way to purge, and a feed that tells you when to.

What it cannot do

Be clear about the limits, because a registry is sometimes sold as more than it is.

It does not know whether a package is malicious. It knows what a feed told it, and feeds lag.

It does not stop a compromised legitimate package. If a maintainer account is taken over and a malicious version is published under a trusted name, the registry serves it, because it looks like a normal release of a package you already depend on. Quarantine helps here and is not a solution.

It does not protect the build from itself. A compromised build script, a malicious GitHub Action, a plugin fetched by a tool that bypasses the registry entirely: all outside its view.

So it is one layer. Its value is position, not intelligence.

Getting there from nothing

If you have no internal registry, the order that gets value fastest:

  1. Stand it up as a transparent proxy, changing nothing about behaviour. Confirm builds still pass.
  2. Block network egress to public registries from build agents, so the proxy is the only path.
  3. Configure your own namespaces to never resolve upstream. This one setting is most of the security benefit.
  4. Turn on malware blocking in report-only mode, read what it would have blocked, then enforce.
  5. Add quarantine, with an override process, once the rest is stable.

Each step is reversible and each one is independently useful, which matters because this is infrastructure work that competes with product work and will be interrupted.

The implication

The interesting question about your registry is not whether you have one. It is whether a build could get a package without going through it.

If the answer is yes, you have a cache. If the answer is no, you have the only control in your pipeline that runs before the code does, and it is worth configuring like one.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.