Artifact Repository
An artifact repository stores and serves packages and build outputs, usually as a caching proxy in front of public registries like npm and PyPI.
Also known as binary repository manager, artifact registry, package repository manager, registry proxy, package mirror
An artifact repository, also called a binary repository manager, stores and serves packages and build outputs (npm tarballs, Python wheels, JARs, container images) over the same protocols as public registries. Most run as a caching proxy in front of npm, PyPI, and Maven Central, which makes them the one point every install in an organization passes through.
That position cuts both ways. An artifact repository makes one decision about each package version, on first fetch: it copies the bytes from the public registry and serves that copy from then on. Package versions are immutable, so the copy never needs refreshing, and the repository never asks again whether it is safe. When a registry deletes a malicious package, the copy in your cache stays.
We reproduced this with Verdaccio 6.11.1, below. After the upstream registry deleted a version, our test artifact repository refreshed its metadata, kept the deleted version, still advertised it as latest, and npm ci installed it with a matching integrity hash. The same property let the boltdb-go typosquat serve a backdoor from the Go module mirror for more than three years while its GitHub repository showed clean code.
Our position: an artifact repository is a cache of bytes, not a source of trust. Caching immutable versions indefinitely is the right design for availability. The mistake is treating the first fetch as a permanent security verdict. Whatever judges packages, whether a policy engine or a package firewall, has to judge every serve against current threat intelligence, cache hits included.
What is an artifact repository?
#An artifact repository is a registry you run or rent that speaks the same HTTP API as a public registry, so unmodified package managers can install from it. Point npm at one with a single line of .npmrc and the client cannot tell the difference.
The term comes from build engineering, where an artifact is any file a build produces: a JAR, a wheel, a container image, a release tarball. Modern repository managers store those and also proxy the public ecosystems your builds consume. Cloud services such as Google Artifact Registry, AWS CodeArtifact, and Azure Artifacts, and self-hosted tools such as Verdaccio for npm and devpi for Python, are built from the same three repository types. The names vary by product. The mechanics do not.
- Hosted (also called local or standard): packages you publish yourself, such as internal libraries and release builds. The repository is their source of truth.
- Remote (also called proxy): a cache of one upstream registry. A miss is fetched from the upstream, stored, and served. A hit is served from storage without contacting the upstream.
- Virtual (also called group): one URL that fans out to several hosted and remote repositories and merges their answers into a single response.
Protocol compatibility is the whole trick. In npm, the metadata document for a package, called a packument, lists every version with a dist.tarball URL for each. A remote repository rewrites those URLs to point at itself, so every tarball download goes through the cache. Here is the same packument from a registry on port 4801 and from a Verdaccio artifact repository in front of it on port 4873:
$ curl -s http://localhost:4801/acme-logger | jq -c '{latest: ."dist-tags".latest, tarball: .versions["1.0.1"].dist.tarball}'
{"latest":"1.0.1","tarball":"http://localhost:4801/acme-logger/-/acme-logger-1.0.1.tgz"}
$ curl -s http://localhost:4873/acme-logger | jq -c '{latest: ."dist-tags".latest, tarball: .versions["1.0.1"].dist.tarball}'
{"latest":"1.0.1","tarball":"http://localhost:4873/acme-logger/-/acme-logger-1.0.1.tgz"}The npm registry API, PyPI's Simple API, Maven's directory layout, and the OCI distribution API for container images share one shape: a mutable index that points at immutable files. That split determines everything that follows.
Why use a central artifact repository?
#Teams use a central artifact repository for availability, speed, a controlled path to the internet, and a record of what was fetched. Each benefit has a cost.
Availability. A cached version installs even when the upstream is down or the author deletes it. The Go team states the goal plainly in the Go module mirror FAQ: "the mirror aims to cache content in order to avoid breaking builds." The cost is the subject of the next two sections. Content you would want gone stays.
Speed and egress control. A CI fleet that pulls the same tarballs thousands of times a day fetches each one from the internet once. With egress rules, the repository can become the only route to public code. The cost is a single point of failure for every build in the company.
Internal publishing. Hosted repositories give private packages a home without publishing them publicly. The cost is that private names now share a namespace with public ones, which is how virtual repositories reintroduce dependency confusion.
A record. The request log shows which versions were fetched, when, and by whom. After the axios compromise, the question every team had to answer was, as we put it in our follow-up on its blast radius, "Did anything you executed resolve Axios during that window?" A repository log answers that for every client that went through the repository, and only for those. A CI job that ran npx against the default registry, or a laptop with a stale .npmrc, left no trace there.
How a remote repository caches a public registry
#A remote repository keeps files indefinitely and caches metadata for minutes, because registries make files immutable and metadata mutable.
The immutability is a registry guarantee. npm's unpublish policy says: "Once package@version has been used, you can never use it again." PyPI's help page says it "does not allow for a filename to be reused, even once a project has been deleted and recreated." If the name and version match, the bytes match, so a cached file can never go stale.
Metadata does change. New versions appear, dist-tags move, and versions get deleted, so repositories refresh metadata on a timer. These are the documented defaults as of October 2026:
| Repository | Metadata | Default cache age |
|---|---|---|
| Google Artifact Registry remote repository | npm package manifest | 5 minutes |
| Google Artifact Registry remote repository | Maven maven-metadata.xml | 5 minutes |
| Google Artifact Registry remote repository | Python index | 1 hour |
| Verdaccio uplink (maxage) | npm packument | 2 minutes |
The figures come from Google's remote repository overview and Verdaccio's uplink documentation.
Here is the trust assumption that breaks. Immutability guarantees the bytes are the same forever. It says nothing about whether those bytes are safe, and the first is easy to read as the second. In our boltdb-go analysis we concluded that immutability "remains a net security benefit," and we still think so. The defect is narrower: the metadata refresh is the cache's only freshness check, and it is not wired to anything that can revoke a file.
Can an artifact repository serve a malicious package after the registry removes it?
#Yes. An artifact repository that cached a malicious version keeps serving it after the registry deletes it, until someone purges the cache. Registries do not push deletions downstream. They stop listing the version.
This is what a takedown looks like from the registry side. npm replaces a removed malicious package with a placeholder version, 0.0.1-security, described as a "security holding package." flatmap-stream, whose version 0.1.1 carried the 2018 event-stream payload, has looked like this since November 29, 2018:
$ curl -s https://registry.npmjs.org/flatmap-stream \
| jq '{latest: ."dist-tags".latest, description: .versions["0.0.1-security"].description, versions: (.versions | keys), modified: .time."0.0.1-security"}'
{
"latest": "0.0.1-security",
"description": "security holding package",
"versions": [
"0.0.1-security"
],
"modified": "2018-11-29T16:56:02.951Z"
}Nothing in that response tells a downstream cache to drop a file it already holds. Whether your repository notices depends on how it merges a refreshed packument with its stored copy.
To test that, we ran three Verdaccio 6.11.1 instances locally with npm 10.9.9: one standing in for the public registry on port 4801, one for an internal registry on port 4802, and one acting as the artifact repository on port 4873, proxying both with the default 2-minute maxage. We published a clean acme-logger@1.0.0 and a 1.0.1 whose postinstall script prints a marker.
storage: ./storage
uplinks:
public:
url: http://localhost:4801/
maxage: 2m
private:
url: http://localhost:4802/
maxage: 2m
packages:
'**':
access: $all
proxy: private publicFirst, an install through the repository:
# 22:56:30 UTC
$ npm install acme-logger --foreground-scripts
> acme-logger@1.0.1 postinstall
> node payload.js
[payload] acme-logger@1.0.1 postinstall ran on vm
added 1 package in 376ms
$ jq '.packages["node_modules/acme-logger"] | {version, resolved, integrity}' package-lock.json
{
"version": "1.0.1",
"resolved": "http://localhost:4873/acme-logger/-/acme-logger-1.0.1.tgz",
"integrity": "sha512-F/aygPCDzbZ110MjuQI3szzEYVMfxYYN7Q/8r46FtpkvjkqAgijK8TWj0GLTac+mQX2EN7RrthlaTw4NmkXrFA=="
}
$ ls proxy/storage/acme-logger/
acme-logger-1.0.1.tgz
package.jsonThen the takedown, nine seconds later, on the upstream:
# 22:56:39 UTC
$ npm unpublish acme-logger@1.0.1 --registry http://localhost:4801/
- acme-logger@1.0.1
$ curl -s http://localhost:4801/acme-logger | jq -c '{latest: ."dist-tags".latest, versions: (.versions | keys)}'
{"latest":"1.0.0","versions":["1.0.0"]}Then the repository, after maxage had expired and it had re-requested the packument from the upstream:
# 22:59:01 UTC
$ curl -s http://localhost:4873/acme-logger | jq -c '{latest: ."dist-tags".latest, versions: (.versions | keys)}'
{"latest":"1.0.1","versions":["1.0.0","1.0.1"]}
# existing project, lockfile from 22:56:30, empty npm cache
$ npm ci --foreground-scripts
> acme-logger@1.0.1 postinstall
> node payload.js
[payload] acme-logger@1.0.1 postinstall ran on vm
added 1 package in 419ms
# new project, no lockfile, through the repository
$ npm install acme-logger --foreground-scripts
> acme-logger@1.0.1 postinstall
> node payload.js
[payload] acme-logger@1.0.1 postinstall ran on vm
added 1 package in 324ms
# new project, no lockfile, straight to the upstream
$ npm install acme-logger --foreground-scripts --registry http://localhost:4801/
added 1 package in 314ms
$ jq -r '.packages["node_modules/acme-logger"].version' package-lock.json
1.0.0| Time (UTC) | Upstream registry | Artifact repository |
|---|---|---|
| 22:56:30 | Lists 1.0.0 and 1.0.1, latest 1.0.1. Serves the 1.0.1 tarball once. | First fetch caches acme-logger-1.0.1.tgz. The postinstall script runs. |
| 22:56:39 | Unpublishes 1.0.1. Latest becomes 1.0.0. The 1.0.1 tarball returns 404. | No change. Its metadata is still within the 2-minute maxage. |
| 22:59:01 | Answers the refresh with only 1.0.0, latest 1.0.0. | Refreshes, keeps 1.0.1, and still reports latest 1.0.1. |
| After | A fresh install direct from the upstream gets 1.0.0. | npm ci from the lockfile and a fresh install both get 1.0.1, served from cache, and the payload runs. |
Three things in that output matter.
The package-lock.json did exactly its job. It recorded the repository URL in resolved and the malicious tarball's hash in integrity, so npm ci verified the hash and installed the malicious bytes. A lockfile pins what you had, compromise included. As we wrote after axios, "Lockfiles make existing installs deterministic. They do not make new installs safe."
The new project with no lockfile also got 1.0.1, because the repository kept latest pointing at a version the upstream had deleted. A developer who started a project after the takedown was compromised through the repository and safe without it.
The repository did refresh. Its log shows a new GET http://localhost:4801/acme-logger to the upstream before it answered the 22:59:01 request, and none of the tarball requests that followed reached the upstream. Verdaccio merged the upstream's shorter version list into its stored copy, which added nothing and removed nothing.
We tested Verdaccio, not every commercial repository, and some may handle upstream deletions differently. The experiment above takes ten minutes to run against your own. One caveat on the payload. npm v12, released in July 2026, no longer runs dependency install scripts by default, which would have stopped this marker at install time. It would not stop code that runs on require(), and it does not remove the cached file.
The boltdb-go case shows the same property at internet scale, with the cache run by the Go team. The attacker let proxy.golang.org cache a backdoored v1.3.1 in November 2021, then rewrote the GitHub tag to point at clean code. Anyone auditing the GitHub repository saw nothing. Anyone running go get received the backdoor.
Virtual repositories reintroduce dependency confusion
#A virtual repository causes dependency confusion when it merges versions of one package name from a private and a public upstream into a single list, and the highest version wins.
Alex Birsan's 2021 research stated the rule directly: "If the package exists on both, it defaults to installing from the source with the higher version number." He reported the technique working inside more than 35 organizations. A virtual repository implements the same rule on the server. Our test repository listed the private upstream first, proxy: private public, and the order made no difference:
$ curl -s http://localhost:4802/acme-internal-utils | jq -c '{latest: ."dist-tags".latest, versions: (.versions | keys)}' # private
{"latest":"1.4.0","versions":["1.4.0"]}
$ curl -s http://localhost:4801/acme-internal-utils | jq -c '{latest: ."dist-tags".latest, versions: (.versions | keys)}' # public
{"latest":"99.0.0","versions":["99.0.0"]}
$ curl -s http://localhost:4873/acme-internal-utils | jq -c '{latest: ."dist-tags".latest, versions: (.versions | keys)}' # repository
{"latest":"99.0.0","versions":["1.4.0","99.0.0"]}
$ npm install acme-internal-utils --foreground-scripts
> acme-internal-utils@99.0.0 preinstall
> node payload.js
[payload] acme-internal-utils@99.0.0 preinstall ran on vm
added 1 package in 420msAn existing range such as ^1.4.0 still resolved to 1.4.0. Anyone adding the dependency fresh got the attacker's 99.0.0, and the payload ran before the install finished.
Upstream priority does not fix this on its own. Google's virtual repository documentation lets you rank upstreams but warns that for an unpinned request "the client might return the latest version of the artifact, regardless of repository priority." Python clients hit the same rule with no server involved. pip installs of PyTorch nightly pulled a malicious torchtriton from PyPI between December 25 and December 30, 2022, because, as the PyTorch team wrote, "the PyPI index takes precedence."
Three mitigations work, and each has a gap.
Route names, not repositories. Send internal names to the hosted repository only. In Verdaccio that is one package rule above the catch-all:
packages:
'acme-internal-*':
access: $all
proxy: private
'**':
access: $all
proxy: public$ curl -s http://localhost:4874/acme-internal-utils | jq -c '{latest: ."dist-tags".latest, versions: (.versions | keys)}'
{"latest":"1.4.0","versions":["1.4.0"]}The gap: every internal name must match a rule, and one forgotten name reopens the hole. npm scopes make the rule easy to write. Create the @acme organization on the public registry too, so nobody else can publish under that scope.
Origin controls. AWS CodeArtifact records how a package's first version arrived and blocks the other path. A package first published internally gets "Publish: ALLOW and Upstream: BLOCK." According to AWS's documentation, enforcement began on July 14, 2022, and packages created before about May 2022 kept ALLOW on both settings until someone changes them. The gap: an internal name has no protection until its first internal publish.
Pins and lockfiles. Exact versions with integrity hashes stop the version race for dependencies you already have. The gap is the previous section: a lockfile pins a bad version as faithfully as a good one, and it does nothing for a dependency added tomorrow.
Where does a package firewall sit relative to an artifact repository?
#A package firewall sits either between clients and the artifact repository, called downstream, or between the repository and the public registries, called upstream. The placement decides whether the firewall ever sees a cache hit.
Figure 1 shows the three paths a package request can take. Laptops and CI runners configured for the repository pass through position A, the downstream firewall, on every install. Inside the repository, a cache hit is served from the remote repository's storage and goes no further. Only a cache miss continues to position B, the upstream firewall, and then to the public registry. Any tool still pointed at the default registry, such as an ad hoc npx or pip run, skips A, the repository, and B, and talks to the public registry directly.
Upstream placement needs no client changes. The repository's remote points at the firewall instead of the registry, so the firewall only sees cache misses. Socket's registry mode documentation says so directly: upstream fits when your mirror caches packages, and "most installs don't hit firewall after first fetch." Every version cached before the firewall was deployed, and every version cached before threat intelligence caught up, keeps flowing. That window is short and real. plain-crypto-js@4.2.1, the dependency injected into the compromised axios releases, was published at 23:59:12 UTC on March 30, 2026, and our scanner flagged it at 00:05:41 UTC. A repository that fetched it in those six minutes holds a copy an upstream firewall will never see again.
Downstream placement checks every request, cache hits included, so a version flagged after it was cached is blocked at the next install. The cost is coverage. Every client must be configured, and anything that resolves against the default registry goes around it. A downstream firewall also adds a hop to every install, and its failure mode matters. As of October 2026, Socket Registry Firewall, part of Socket Firewall Enterprise, defaults to fail_open: true, which keeps builds running when the Socket API is unreachable and lets unchecked packages through while it is. Blocked requests get an HTTP 403 with a reason. A minimal downstream route looks like this:
path_routing:
enabled: true
domain: sfw.your_company.com
routes:
- path: /npm
upstream: https://registry.npmjs.org
registry: npmWhichever position you pick, the check has to read package contents, not advisories. A repository scan that matches versions against CVE databases would have flagged neither acme-logger@1.0.1 nor boltdb-go/bolt. Malware does not get a CVE, so a CVE scan cannot flag it.
| Placement | Sees cache hits | Client changes | What it misses | Use it when |
|---|---|---|---|---|
| Upstream only (B) | No | None | Versions cached before detection or before deployment, and anything fetched while failing open | You cannot change client config yet, and you will purge the cache whenever a cached version is flagged |
| Downstream only (A) | Yes | Every client | Clients that bypass the repository | You control registry settings on laptops and CI |
| Downstream plus blocked egress | Yes | Every client, plus network rules | Little beyond explicit exceptions | You already run a repository and can block direct access to public registries |
No placement catches a malicious version that nobody has detected yet. That gap is what a release-age delay, covered below, is for.
Our verdict: put the check downstream, where it sees every install, and block direct egress to public registries so nothing goes around it. Upstream-only placement is a reasonable first step for a team that cannot touch clients yet, provided a flagged version also triggers a purge of the cached copy.
Repositories should record verdicts, not just bytes
#The design gap is narrow. An artifact repository records what it fetched but not whether that is still safe to serve. Three changes would close most of what this article demonstrates.
- Propagate takedowns as tombstones. When an upstream stops listing a version, mark the cached copy unservable, with a reason, and keep the bytes. A takedown should reach the cache within one metadata refresh, not never.
- Look up the verdict on every serve. A verdict about a version belongs in a short-TTL lookup on each request, not stored next to the file forever.
- Enforce release age at the repository. A dependency cooldown set in client config can be overridden by that client's
.npmrc. One enforced by the repository applies to every client behind it. acme-logger@1.0.1 would never have entered our cache.
The harder problem is the one tombstones expose. A cache that deletes a malicious file destroys the evidence you need to answer whether you ran it. A cache that keeps the file keeps serving it. Verdaccio, in our test, chose the second, and a repository tuned for availability has every incentive to do the same. The right answer is both: keep the bytes, refuse to serve them, and log every client that still asks.
