Back to glossary

Package Firewall

A package firewall is a proxy between package managers and registries that blocks malicious or policy-violating package downloads before they reach disk.

Also known as dependency firewall, repository firewall, registry firewall, package manager firewall, open source firewall, malicious package firewall, supply chain firewall

A package firewall is a proxy between package managers and registries such as npm and PyPI. It checks every package version the package manager requests, including transitive dependencies, against malware verdicts and policy, and refuses the download when the check fails. Also called a dependency or repository firewall, it stops malicious code before any of it reaches disk.

A package firewall works because an install is just HTTP. The package manager fetches metadata to choose versions, then fetches one tarball per package, and every tarball URL names the package and its version. The firewall looks that pair up and either forwards the request or answers 403. Nothing from a blocked version reaches the machine, so its install scripts never run and its code is never imported.

That turns one verdict into a non-event on every machine at once. In the axios compromise, the payload-carrying dependency plain-crypto-js@4.2.1 was published at 23:59:12 UTC on March 30, 2026. Socket flagged it as malware at 00:05:41 UTC. The attacker published axios@1.14.1, the release that pulled it in, at 00:21:58 UTC. The verdict existed 16 minutes before the poisoned axios did. npm replaced the payload with a security placeholder at 03:25:11 UTC, according to the registry's own time field. For 3 hours and 19 minutes, the only thing between a fresh install and a remote access trojan was whether something enforced a verdict that already existed.

We think every install path should go through a package firewall: developer laptops, CI runners, container builds, and AI agents. We also think the category is oversold. A package firewall is a lookup, not a detector. It enforces verdicts that already exist, on traffic it can see, and those two limits decide how much it protects you.

The same timeline as a table. Times are UTC; publish times come from the npm registry's time field, and the detection time from Socket's report.

Time (UTC)EventFirewall state
Mar 30, 23:59:12plain-crypto-js@4.2.1 published with a postinstall dropperNo verdict yet; would be allowed
Mar 31, 00:05:41Socket flags plain-crypto-js@4.2.1 as malwareVerdict exists; download blocked from here on
Mar 31, 00:21:58axios@1.14.1 published, adding plain-crypto-js as a dependencyInstalls of axios@1.14.1 fail on the dependency fetch
Mar 31, 01:00:57axios@0.30.4 published with the same dependencySame
Mar 31, 03:25:11npm replaces plain-crypto-js with 0.0.1-securityRegistry no longer serves the payload

What is a package firewall?

#

A package firewall is an enforcement point on the two kinds of HTTP request every package install makes: the metadata request and the tarball request. To see why that is enough, watch what a package manager does on the wire. Here is npm 12.2.1 installing one package that has one dependency:

Shell Session
$ npm install is-odd@3.0.1 --loglevel http 2>&1 | grep 'fetch GET'
npm http fetch GET 200 https://registry.npmjs.org/is-odd 85ms (cache miss)
npm http fetch GET 200 https://registry.npmjs.org/is-number 33ms (cache miss)
npm http fetch GET 200 https://registry.npmjs.org/is-number/-/is-number-6.0.0.tgz 44ms (cache miss)
npm http fetch GET 200 https://registry.npmjs.org/is-odd/-/is-odd-3.0.1.tgz 47ms (cache miss)

The first two requests fetch packuments, the registry's metadata document for a package. The resolver reads each one, picks a version that satisfies the declared range, and discovers the next layer of dependencies. The abbreviated packument format, requested with Accept: application/vnd.npm.install-v1+json, carries what the resolver needs:

JSON
{
  "name": "is-odd",
  "dist-tags": { "latest": "3.0.1" },
  "versions": {
    "3.0.1": {
      "dependencies": { "is-number": "^6.0.0" },
      "dist": {
        "tarball": "https://registry.npmjs.org/is-odd/-/is-odd-3.0.1.tgz",
        "integrity": "sha512-CQpnWPrDwmP1+SMHXZhtLtJv90yiyVfluGsX5iNCVkrhQtU3TQHsUWPG9wkdk9Lgd5yNpAg9jQEo90CBaXgWMA=="
      }
    }
  }
}

Nobody asked for is-number. npm fetched it because is-odd declares ^6.0.0, which is how every transitive dependency enters a build. The last two requests download tarballs. npm hashes each tarball, compares the digest to dist.integrity, and only then extracts it and runs whatever install scripts the package declares and the configuration allows.

A package firewall sits where those requests go, and it has exactly two places to act. At the metadata request, it can remove a blocked version from the packument so the resolver never selects it. At the tarball request, it can refuse the download. The integrity hash is why there is no third option. A proxy that rewrote a tarball to strip a payload would also have to rewrite dist.integrity. That breaks every lockfile that already records the original digest, and it invalidates the registry signature, which signs name@version:integrity. A package firewall that changed bytes would be indistinguishable from the attack it exists to stop. In practice, a package firewall can allow, hide, or deny. It cannot sanitize.

The diagram shows one install through a package firewall in three parts. First, the metadata request: npm asks the firewall for /ms, the firewall fetches the packument from the registry, optionally removes blocked versions, and returns the result, from which npm's resolver picks a version satisfying ^2.1.0. Second, the tarball request: npm asks for /ms/-/ms-2.1.3.tgz, and the firewall looks up a verdict for ms@2.1.3. If the version is blocked, the firewall answers 403 and never contacts the registry. If it is allowed, the firewall forwards the request and returns the registry's tarball with its bytes unchanged, and npm verifies its SHA-512 digest against dist.integrity before extracting it. Third, a local cache hit: npm already has the tarball, makes no request, and the firewall never sees the install.

Why can't a network firewall block a malicious package?

#

A network firewall cannot block a malicious package because it sees only the registry's hostname, and good and bad packages come from the same host. The package identity lives in the URL path, /plain-crypto-js/-/plain-crypto-js-4.2.1.tgz, and HTTPS encrypts the path. A network firewall can read the destination address and the server name the client sends in the TLS handshake (SNI, RFC 6066). For lodash and for malware alike, that name is registry.npmjs.org. An egress rule that permits that host permits every package on it. A rule that blocks it breaks every build.

The older vocabulary for these controls, whitelisting and blacklisting, comes from that network and endpoint world: lists of hosts, ports, or executable hashes a system will admit or refuse. A web application firewall (WAF) is the HTTP-aware version, but it inspects requests coming into your application, not your build's outbound fetches. A package firewall applies the list idea one layer up. It is an application-layer proxy that speaks registry protocols, so it can key decisions on ecosystem, package name, and version instead of IP address. To read the path, it either terminates TLS itself with a certificate the package manager has been told to trust, or it becomes the registry the package manager talks to. Those two choices are the deployment modes below.

How does a package firewall block a malicious npm package?

#

A package firewall blocks a malicious npm package by hiding the version from metadata, refusing its tarball with HTTP 403, or both. The difference matters more than most descriptions admit, so we built the smallest package firewall that shows it. It proxies registry.npmjs.org, treats ms@2.1.3 as malware, and filters metadata when FILTER=1. ms@2.1.3 is a harmless version standing in for a malicious one. We picked it because ^2.1.0 has older versions to fall back to, exactly as ^1.14.0 had axios@1.14.0.

JavaScript
// A toy package firewall for npm: a registry proxy with a blocklist.
import http from "node:http";

const UPSTREAM = "https://registry.npmjs.org";
const SELF = "http://localhost:4873";
const BLOCKED = new Set(["ms@2.1.3"]);       // verdicts: name@version
const FILTER_METADATA = process.env.FILTER === "1";

http.createServer(async (req, res) => {
  const url = new URL(req.url, SELF);
  const tgz = url.pathname.match(/^\/((?:@[^/]+\/)?[^/]+)\/-\/[^/]+-(\d[^/]*)\.tgz$/);

  if (tgz) {                                  // 1. tarball download
    const id = `${decodeURIComponent(tgz[1])}@${tgz[2]}`;
    if (BLOCKED.has(id)) {
      console.log("BLOCK", id);
      res.writeHead(403, { "content-type": "text/plain" });
      return res.end(`Blocked by policy: ${id} is known malware\n`);
    }
    console.log("ALLOW", id);
    const up = await fetch(UPSTREAM + url.pathname);
    res.writeHead(up.status, { "content-type": "application/octet-stream" });
    return res.end(Buffer.from(await up.arrayBuffer()));
  }

  // 2. metadata ("packument") request
  const up = await fetch(UPSTREAM + url.pathname, {
    headers: { accept: req.headers.accept ?? "application/json" },
  });
  if (!up.ok) { res.writeHead(up.status); return res.end(); }
  const doc = await up.json();
  for (const [v, meta] of Object.entries(doc.versions ?? {})) {
    if (FILTER_METADATA && BLOCKED.has(`${doc.name}@${v}`)) {
      delete doc.versions[v];
      console.log("HIDE ", `${doc.name}@${v}`);
      continue;
    }
    meta.dist.tarball = meta.dist.tarball.replace(UPSTREAM, SELF);
  }
  for (const [tag, v] of Object.entries(doc["dist-tags"] ?? {})) {
    if (!doc.versions?.[v]) delete doc["dist-tags"][tag];   // no tag may point at a hidden version
  }
  res.writeHead(200, { "content-type": "application/json" });
  res.end(JSON.stringify(doc));
}).listen(4873, () => console.log("firewall on", SELF, FILTER_METADATA ? "(filtering)" : ""));

Run 1: tarball blocking only. A fresh install of a range, with metadata filtering off:

Shell Session
$ export npm_config_audit=false npm_config_fund=false
$ node firewall.mjs &
$ npm install 'ms@^2.1.0' --registry http://localhost:4873/ --cache ./cold
npm error code E403
npm error 403 403 Forbidden - GET http://localhost:4873/ms/-/ms-2.1.3.tgz
npm error 403 In most cases, you or one of your dependencies are requesting a package version that is forbidden by your security policy, or on a server you do not have access to.

The resolver picked 2.1.3 because the packument still listed it, and the tarball request hit the block. npm's own E403 text already anticipates this deployment: "forbidden by your security policy." The install fails closed, which is the correct security outcome. It is also a broken build that a developer now has to fix by hand.

Run 2: metadata filtering on. The same install:

Shell Session
$ FILTER=1 node firewall.mjs &
$ npm install 'ms@^2.1.0' --registry http://localhost:4873/ --cache ./cold
added 1 package in 852ms
$ grep -A1 '"node_modules/ms"' package-lock.json
    "node_modules/ms": {
      "version": "2.1.2",

The resolver never saw 2.1.3 and chose 2.1.2. That is the behavior you want during an incident like axios: ranges keep resolving, to the newest version that has no block.

Run 3: a lockfile that already names the bad version. The lockfile was created earlier by a direct install from the public registry, which also filled a cache directory called ./warm. Metadata filtering is still on, and this run uses an empty cache:

Shell Session
$ grep -A2 '"node_modules/ms"' package-lock.json
    "node_modules/ms": {
      "version": "2.1.3",
      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
$ npm ci --registry http://localhost:4873/ --cache ./cold
npm error code E403
npm error 403 403 Forbidden - GET http://localhost:4873/ms/-/ms-2.1.3.tgz

Metadata filtering did nothing here, because npm ci never asked for metadata. The lockfile names the exact tarball, so only the download check could stop it. The lockfile's resolved URL points at registry.npmjs.org, yet the request went to the firewall. npm's replace-registry-host setting defaults to npmjs, which rewrites public-registry URLs in a lockfile to the configured registry. A package firewall deployed as the registry therefore covers existing lockfiles without anyone editing them.

Two conclusions follow. First, hiding versions is a usability feature and the tarball check is the security boundary. Lockfiles, direct tarball URLs, and cached metadata all skip the listing. Socket's registry-mode documentation draws the same line: metadata filtering is off by default, and "Listing removal does not stop installs. Downloads are checked at download time" (configuration reference). Second, metadata filtering is easy to get subtly wrong. Our first draft of the toy repointed the latest dist-tag at the last remaining key in versions when it hid the real latest. npm install ms then resolved to 4.0.0-nightly.202508271359. The version above deletes any tag that points at a hidden version, and npm falls back to the highest stable release, 2.1.2.

Where a package firewall sits

#

A package firewall only protects traffic that passes through it, so placement decides coverage. Three placements are in common use, and as of October 2026 Socket Firewall supports all three.

Wrapper mode runs the firewall per command. sfw npm install starts an ephemeral HTTP proxy, routes that subprocess's package traffic through it, and checks each request against the Socket API before the package is fetched (Socket Firewall launch). It needs no infrastructure, which makes it the right tool for a laptop or a CI job. It covers nothing the wrapper does not launch. Since December 2025, Docker Hardened Images for Node.js, Python, and Rust ship with sfw already wired into npm, yarn, pnpm, pip, uv, and cargo, so dependency installs inside those container builds are wrapped by default.

Registry mode runs the firewall as a long-lived service that package managers use as their registry, for example npm config set registry https://firewall.company.com/npm/. Every configured client goes through it, including npm ci from an existing lockfile, and one host can front npm, PyPI, Maven, Cargo, RubyGems, NuGet, Go, Conda, and Open VSX (registry mode docs).

Upstream mode puts the firewall behind an artifact repository, the internal mirror many companies already run, so the mirror pulls through it and developer configuration does not change. The cost is stated in Socket's own upstream deployment guide: packages already cached in the mirror are served without ever reaching the firewall, and the firewall scans each package version once. A version that entered the mirror before its verdict stays servable from the mirror's cache.

PlacementWhat passes through itWhat bypasses itBest fit
Wrapper (sfw npm install)Commands launched by the wrapperUnwrapped installs; local package-manager cache hitsLaptops, individual CI jobs, container builds
Registry mode (downstream)Every request from every client configured to use it, lockfile installs includedClients still pointed at the public registry; local cache hitsOrganization-wide enforcement
Upstream of a mirrorThe mirror's cache missesEverything already cached in the mirror, plus local cache hitsAdding checks without touching client configuration

Our verdict: make registry mode the default for an organization, because it sees every request from every configured client, including installs from existing lockfiles. Use wrapper mode where you cannot change registry configuration. Put a package firewall upstream of a mirror only if you also purge the mirror's cache when a verdict changes. Without that, it is an admission check on first fetch, not a firewall.

Placement also decides what happens when the firewall cannot reach its verdict service. Socket's registry mode defaults to fail_open: true, which allows packages with a warning when the API times out or errors, and its docs recommend failing closed for production. Unknown versions get a separate switch, fail_open_unscanned, which also defaults to allowing. Cached results stay fresh for 600 seconds by default, so a new verdict can take up to that long to reach a version the firewall has already allowed (configuration reference). Those defaults choose availability for development and CI. Builds that ship to customers should choose enforcement.

Should a package firewall use an allowlist or a blocklist?

#

A package firewall should block by verdict and reserve exact-version allowlists for the few systems that can afford to approve every package. A blocklist, historically called a blacklist, is default-allow: refuse what is known to be bad and admit the rest. An allowlist, historically a whitelist, is default-deny: admit only approved packages and versions.

Allowlisting works well for application control on endpoints, where the set of approved executables is small and changes slowly. A dependency tree is neither. Every caret range is a standing request for versions that did not exist when anyone approved the package, and one minor release can add new transitive dependencies. An exact-version allowlist turns each of those into an approval ticket. In practice that decays into approving whole packages, and an allowlist entry for axios would have admitted axios@1.14.1.

Most deployments sit between the two: policy over verdicts. Block confirmed malware, hold versions younger than a threshold, and decide what to do with versions nobody has scanned yet.

PolicyUnknown new versionFrictionWhat it misses
Blocklist of known malwareAllowedLowAnything not yet flagged
Exact-version allowlistDenied until approvedHigh: every new transitive version needs approvalMalicious releases of approved packages, once approvals drift to whole packages
Verdicts plus a cooldownHeld until it is N days old, then allowed if no verdictModerate: urgent patches need an exceptionMalware that stays hidden longer than the cooldown

Our verdict: block confirmed malware everywhere, add a cooldown, and keep exact-version allowlists for high-assurance systems such as release-signing hosts. Socket's cool-down policy raises a recentlyPublished alert for versions younger than an org-wide threshold of 0 to 365 days, and Socket Firewall blocks the request when the policy action is error. A dependency cooldown enforced in the firewall has one property a package-manager setting lacks: a project's .npmrc cannot turn it off.

Package firewall vs software composition analysis

#

A package firewall and software composition analysis (SCA) answer different questions at different times. SCA reads the manifests and lockfiles in a repository, usually on a pull request or a schedule, and reports what the project declares. A package firewall sees what machines actually fetched, including installs no repository declares: a tool run with npx in a CI step, a global install, a package an agent added and later removed.

Our analysis of the hidden blast radius of the axios compromise framed the exposure question as "Did anything you executed resolve Axios during that window?" A lockfile cannot answer that. Darcy Clarke, founder of vlt and a former npm engineering manager, explained why in that post: "Lockfiles don’t prevent net-new installs when updating/adding new dependencies. That’s the point." A package firewall's log can answer it. Socket Firewall Enterprise records each attempted download with the machine ID, the time, and whether it was allowed or blocked (Socket Firewall Enterprise).

Neither a package firewall nor SCA should key on CVEs for this class of attack. Malicious packages do not get CVEs, so a policy driven by CVE severity would have allowed plain-crypto-js@4.2.1 at every step.

Package manager defaults are moving the same direction. npm v12 shipped with dependency install scripts off by default in July 2026, following pnpm 10. That removes the automatic execution path, and we think it was overdue. It does not keep the code off disk. A skipped postinstall leaves the package in node_modules, where anything that imports it runs it. A package firewall is the control that keeps the bytes out.

What a package firewall cannot see

#

A package firewall cannot see installs that never make a request. Run 4 repeats Run 3 with one change: it uses the ./warm cache that the earlier direct install populated.

Shell Session
$ npm ci --registry http://localhost:4873/ --cache ./warm --loglevel http
npm http cache ms@http://localhost:4873/ms/-/ms-2.1.3.tgz 0ms (cache hit)
added 1 package in 460ms
$ node -p "require('./node_modules/ms/package.json').version"
2.1.3

The firewall logged nothing. npm matched the lockfile's integrity hash against its content-addressed cache and installed the blocked version without a single request. Socket's documentation for the free tier says the same and recommends clearing the package manager's cache before first use. Ephemeral CI runners and fresh container layers mostly avoid this; long-lived laptops do not.

A package firewall cannot block what has no verdict. plain-crypto-js@4.2.1 was installable for 6 minutes and 29 seconds before Socket flagged it. As of October 2026, Socket Firewall Free blocks only malware confirmed by human review and warns on AI-detected malware without blocking it. In the tinycolor compromise, Socket's AI flagged the malware within an hour of publication (Socket Firewall launch). Under the free tier's policy, that detection would have produced a warning, and a block only after a human confirmed it.

A package firewall checks the artifact that was requested, not the code inside it. Socket found @shadanai/openclaw shipping the same plain-crypto-js dropper deep in a vendored path of its own tarball. No request for plain-crypto-js ever happens in that install, so protection depends on a verdict for @shadanai/openclaw itself. That verdict has to come from analyzing what is actually in the tarball, not from its manifest or its declared dependencies.

A package firewall cannot see code that never comes from a registry. Git dependencies and remote tarball URLs bypass registry routing entirely. npm v12 now refuses both by default unless --allow-git or --allow-remote permits them. The plain-crypto-js dropper fetched its second stage from the attacker's server on port 8000, not from any registry. The firewall's job was to stop the first stage, because nothing in the package path would ever see the second.

The open problem is the version nobody has analyzed yet. A package firewall that fails open on unscanned versions admits it. One that fails closed breaks builds whenever a dependency ships a release faster than analysis finishes. A short cooldown, enforced in the firewall, is the best compromise we know of. It turns "unknown" into "not yet" for long enough that analysis catches up, and it leaves detection speed as the number that matters.