Tidewaiter: a registry proxy that checks packages before you install them

I built Tidewaiter, a package firewall. Package managers point at it instead of the public registry. It checks each package version against local policy and OSV before download, caches approved artifacts, and says why when it blocks one.

A lockfile pins a version. It does not say if that version is safe. A phished maintainer can publish a bad version, a typosquat sits one keystroke from a real package, and an install script runs on a laptop with cloud credentials. Most teams have nothing between the package manager and the registry.

The risk

The attacks are boring and they work:

  • Compromised maintainers. An attacker gets publish access and ships a malicious patch release. Everyone with a caret range pulls it on their next install. In September 2025, phished maintainer credentials put malicious versions of chalk, debug and more than a dozen other packages on npm, with over two billion weekly downloads between them, and the Shai-Hulud worm spread itself through stolen npm tokens the same month.
  • Typosquats. lodahs instead of lodash. One typo, and the package runs.
  • Install scripts. npm lifecycle scripts, Python build backends and Gradle plugins all run code at install or build time, before any test or review sees it.
  • Known vulnerabilities. Less dramatic and far more common. A critical CVE in a transitive dependency ships because nobody looked.

All four hit at download time, on developer machines and CI runners. A later scan is too late for a machine that already ran the code.

Commercial options

Socket analyzes packages for malware and risky behavior as well as CVEs. Its Socket Firewall is "an HTTP/HTTPS proxy server that sits between package managers and package registries" and checks each request against Socket's data before it reaches the registry. It blocks confirmed malware, and the enterprise version adds a registry mode that package managers point at directly.

JFrog comes at it from the repository manager. Artifactory remote repositories proxy and cache public registries. JFrog Curation enforces security and license policies "before they're even used", at the point a package enters a remote repository, with conditions for malicious packages, critical CVEs, licenses and package maturity. Xray handles gating for what's already in local repositories.

Both do four things:

  1. One door. Package managers fetch from a proxy you control instead of the public registries.
  2. A decision before download. The proxy checks each package version against threat data and your policy before bytes reach a machine.
  3. A cache. The proxy serves approved artifacts from your own storage: faster, and still up when the registry is slow or down.
  4. An explanation. A developer whose install fails can find out why, and what to do next.

How it works

Tidewaiter is a Go service. Package managers point at it as their registry:

npm config set registry http://localhost:8080/npm/
pip config set global.index-url http://localhost:8080/pypi/simple
export GOPROXY=http://localhost:8080/go

Maven and Gradle use it as a mirror. It does not intercept TLS: it only sees traffic pointed at it, and no machine needs a custom CA.

It supports npm-compatible registries (so npm, pnpm and Yarn), PyPI's Simple API (pip and uv), the Maven repository layout (Maven and Gradle), and the Go module proxy protocol. A static ecosystem covers fixed build assets, like Gradle distributions, that have no package coordinate and are pinned by checksum instead.

Every request goes through the same steps.

1. Identify the package

Each route has an ecosystem adapter that turns a URL into a package and version, written as a purl: /npm/shell-quote/-/shell-quote-1.9.0.tgz becomes pkg:npm/[email protected]. Metadata requests (the npm packument, the PyPI index page) don't name a version and pass through to the next step without a vulnerability check. When the adapter can't identify an artifact, the request is unknown, and the config decides whether unknown means warn or block.

2. Apply local policy

Policy files are purl globs:

deny:
  - "pkg:npm/lodahs@*"
warn:
  - "pkg:pypi/django@5.*"
allow:
  - "pkg:golang/golang.org/x/[email protected]"

Deny always wins, so a broad allow can't mask an explicit deny. An allow rule naming an exact version skips the threat check, which is how you take an exception for a package you've reviewed. Warn rules and wildcard allows still block malware, since a wildcard would also exempt the next, possibly compromised, release.

3. Check threat data

Tidewaiter looks up anything policy didn't decide in OSV, which aggregates the GitHub advisory database, the ecosystems' own advisories, and the OpenSSF malicious-packages feed (published under MAL- IDs). Findings at or above a CVSS threshold block; findings below it get the default action, warn in the example config. Tidewaiter caches results for a few hours, and if OSV is unreachable you choose whether to fail open or closed.

OSV's MAL- records, and GitHub advisories tagged CWE-506 (embedded malicious code), usually carry no CVSS score. Malware always blocks, whatever the threshold, the default action or a matching warn rule says, and a malicious result outlives the OSV cache so an OSV outage can't serve a package already seen to be malicious. In production, [email protected] (the payload behind the 2018 event-stream compromise) and [email protected] (hijacked in 2021) get a 403. The cooldown looks up publish times from each registry: the npm packument, PyPI's upload time for the requested file, the Go proxy's .info, and Maven's Last-Modified.

4. Explain the decision

A block is a 403 with the reason, written for the person whose install just failed:

Blocked by Tidewaiter: [email protected] has GHSA-pqg4-j6r4-53mv (CVE-2026-102422),
severity 9.2 >= threshold 9.0: shell-quote: `quote()` command injection ... Affected: >=1.8.4,
<1.11.0. Fixed in 1.11.0; upgrade to a fixed version (update the lockfile, or override the
transitive dependency). Details: https://osv.dev/vulnerability/GHSA-pqg4-j6r4-53mv.
Request ID: 1234567890123456789. Exceptions: <decision.exceptions_url>

Each kind of block has its own next step. A vulnerability says which version fixes it. Malware says to remove it and treat the machine as compromised. A deny rule says to pick something else or ask for an exception, and every message links to wherever your team takes exceptions.

Only npm prints a 403 body. pnpm, Yarn, pip, uv, Maven and Gradle print the status and the URL and nothing else. So Tidewaiter also puts the explanation in X-Package-Firewall-* headers, and serves an /explain endpoint that takes the URL from the error, or a purl, and re-runs the same decision without downloading anything:

curl -fsS --netrc -G https://packages.example.com/explain \
  --data-urlencode "url=https://packages.example.com/npm/shell-quote/-/shell-quote-1.9.0.tgz" \
  --data-urlencode format=text

A CI job can call it for every failed URL in its log. If a firewall blocks without a reason, developers go around it. If it names the advisory and the fixed version, they update the lockfile.

5. Rewrite metadata so nothing goes around it

A proxy only protects you if every download goes through it. Registry metadata names where to download from: an npm packument lists tarball URLs on registry.npmjs.org, and a PyPI index page links wheels on files.pythonhosted.org. A client that follows those links fetches the artifact directly, skipping the firewall without anyone noticing.

So Tidewaiter rewrites metadata responses to point back at itself, as a stream, so a 28 MB packument never sits in memory.

6. Serve from the cache

Tidewaiter caches approved artifacts on the local filesystem or in S3. The cache is deliberately much narrower than a general HTTP cache:

  • The decision runs before every cache lookup, so a package blocked today can't be served from yesterday's cache entry.
  • It caches only exact artifacts with a concrete name and version, and only clean responses: a 200, no ranges, no conditional requests, no cookies, nothing marked private or no-store.
  • It stages a hit to local disk and checks its recorded length and SHA-256 before a byte goes to the client. A missing, truncated or corrupt entry is just a miss.
  • Public npm artifacts respect the registry's freshness headers, and an expired entry means a new fetch, never a stale fallback.

With several replicas, a DynamoDB lease makes sure a cold artifact is fetched from upstream once, not once per pod, and an upstream that answers 429 cools down every replica at once. That matters the first time a few hundred CI jobs hit an empty cache together. The OpenTofu modules create the S3 bucket and DynamoDB table with least-privilege IAM policies.

For Gradle there's tidewaiter prewarm, which reads the committed lockfiles and verification metadata, downloads every artifact through the firewall checking each SHA-256, then does it again and fails unless every artifact is a cache hit the second time. Run it as one controlled job before a burst of CI traffic: a miss on the second pass means the cache isn't working.

Limits

Tidewaiter acts on OSV and your policy. It does not look at package code or intercept TLS. It does not host your own packages, and it has no Artifactory or Nexus discovery. It covers four ecosystems.

At Oscilar it sits in front of CI in about twenty repositories. In the 30 days to November 3 it answered about 119 million requests and blocked 1,162 downloads of 27 distinct package versions. Of the 35 million artifact downloads eligible for the cache, 89.6% were served from it: 99.5% or better for npm, PyPI and Go, and 84.5% for Maven Central.

Tidewaiter is on GitHub at travisjeffery/tidewaiter under the Apache 2.0 license. The README has a quick start that runs it locally against the public registries.

Next: what running it taught me about caching, from CDN cookies to cold-cache stampedes.