Skip to main content

blastgate

Run package installs with no credentials available and no network egress except an explicit allowlist.

pip install blastgate
blast npm ci

Why

In September 2025 the Shai-Hulud worm read npm tokens from ~/.npmrc across roughly 500 packages — including ones published by Zapier, PostHog and Postman — and used them to republish itself. It exfiltrated to public GitHub repositories it created under its victims' own accounts, exposing about 26,000 repos (Unit 42, Krebs).

It needed two things: credentials it could read, and somewhere to send them.

Under blastgate an install has neither. This is a real run of a project with a git dependency, straight from the audit log:

npm-resolve  ALLOW  github.com            ← fetched before any package code ran
npm          ALLOW  registry.npmjs.org
npm          DENY   codeload.github.com   ← the install reached for a forge; refused

The install succeeded. The forge was unreachable the entire time package code was executing, because git dependencies are fetched in a separate phase before any of it runs.

With a private registry configured, the token is not in the sandbox at all. A probe reading every environment variable and every readable file finds nothing:

ENV_HITS []
FILE_HITS []

That is a test in the suite, not a claim in a README.

What this does not do

It does not tell you whether a package is malicious. It assumes the answer is yes and constrains what one can reach.

Status: pre-alpha. Every control is built and demonstrated against running containers — 448 tests, CI on Linux and macOS, Docker and Podman. It has never been run against a live worm. A payload can still use the registry itself as an exfiltration channel, and credential brokering is untested against a real private registry.

Section 8 of the threat model is longer than section 6 on purpose: it lists everything this does not defend against, and the attack corpus counts its own disclosed gaps as failures rather than omitting them. That is why the published score is 14 of 16 and not 100%.

Read section 8 before relying on this.

The problem

Registry supply chain worms follow one structure: a maintainer's token is stolen, trojanized versions are published across every package that account controls, install-time code execution harvests credentials from the machine, and the stolen tokens publish the next wave. Existing tooling is good at identifying which versions were malicious, after the fact. That is incident response, not prevention.

The thesis

There is no bounded list of ways to obtain code execution during an install, so that link cannot be reliably cut. The two links after it are structurally narrow:

  • Credential theft requires reading files an install has no business reading.
  • Exfiltration requires reaching a destination an install has no business reaching.

Two controls, no detection. Isolation: the install sees the project directory and nothing else. Egress control: the install reaches a short allowlist; everything else is denied and logged.

It should not matter how the code got execution, because there is nothing to steal and nowhere to send it.

Blastgate does not tell you whether a package is malicious. It assumes the answer is yes and constrains what that can accomplish.

What works today

Phase Component Status
0 Threat model Written
1 Policy engine — allowlist load, host matching, allow/deny Working
2 Isolation — container topology, credential stripping Working
3 Enforcement — egress proxy, hash-chained audit log Working
4 Executable attack corpus Working

Does it break real installs?

The corpus measures what blastgate stops. This measures what it breaks, which is the number that decides whether anyone keeps it switched on.

Every project is installed twice: once in a plain container with full network access, and once under blastgate. Without that control a failure means nothing — the project might simply be broken, and counting that as a false positive would hide real breakage in noise.

python scripts/compat_check.py

16 of 16 projects install unchanged under blastgate. No false positives found.

Project Ecosystem Why it is here Verdict
express axios got prettier npm real repos, pinned to release tags compatible
esbuild npm postinstall downloads a platform binary compatible
sharp npm native module, prebuilt binaries compatible
is-odd#3.0.1 npm git dependency via the resolve phase compatible
webpack + eslint npm wide, deep transitive graph compatible
requests click pypi pure-python wheels compatible
cryptography pypi compiled wheel compatible
pendulum pypi build isolation fetches its own backend compatible
fastapi uvicorn pypi wide transitive graph compatible
requests (git) pypi git dependency via the resolve phase compatible
serde cargo sparse index plus crate downloads compatible
tokio clap cargo hundreds of transitive crates compatible
anyhow (git) cargo git dependency via the resolve phase compatible
date-fns npm — excluded

date-fns is excluded because its control install fails too: oxlint@^1.65.0 floats forward into a peer conflict with its own pinned sibling, so the tag does not install today under any sandbox. That is upstream's conflict, not blastgate's. The report prints the ERESOLVE output so the exclusion can be checked rather than taken on trust.

Both runs are retried the same number of times. An unretried control silently excludes a project on a transient failure, which makes the rate look better by measuring less — got did exactly that on one run and installed fine on the next two. Deterministic failures skip the retries.

Why this number is weaker than it looks

Read it as "no false positives found in sixteen projects", not as a rate.

  • The two hardest npm cases passed for reasons unrelated to this design. esbuild and sharp were included because they historically downloaded binaries from GitHub releases at postinstall, which blastgate denies. Both now ship platform binaries as npm optional dependencies served from the registry. The ecosystem moved somewhere convenient; that is luck, and it can move back.
  • The pypi and cargo cases are synthetic manifests, not cloned projects. They exercise real packages, real transitive graphs and real git dependencies, but not real repository layouts.
  • Untested entirely: private registries, authenticated .npmrc, workspace monorepos, yarn, and pnpm.

When something is denied

A refusal tells you what to do about it:

blast: 1 request(s) reached for host(s) the allowlist does not name: artifacts.internal.test
    if these are legitimate, add them to /path/to/project/.blastgate.yaml:

      version: 1
      allow:
        - host: artifacts.internal.test
          reason: "why this install needs it"

    a host added there is reachable by every install in this project,
    including one running code from a package you did not write.

.blastgate.yaml can only add hosts. Any key that could disable the proxy, open a port, or turn off anchoring is refused rather than ignored, and every entry needs a reason — adding a host widens egress, and the diff should say why. Hosts added this way appear as [project] in the audit log.

In CI

- uses: Arjunsilwal/blastgate@v0.1.0
  with:
    ecosystem: npm
    command: npm ci

Exit code 3 is the one worth gating on: the install succeeded, and something reached for a host the allowlist does not name. Without a distinct code that is invisible to a pipeline, because nothing failed.

Denials are not all equal, and treating them as if they were is how a gate gets switched off. An npm install with a git dependency reaches for codeload.github.com, is refused because the forge is deliberately unreachable during the install phase, and finishes from the local mirror. That is correct behaviour, not an incident:

denied: [('codeload.github.com', 'known')]     -> exit 0
denied: [('exfil.attacker.test', 'UNKNOWN')]   -> exit 3

--json PATH writes a machine-readable summary. Full details in docs/ci.md.

What the attack corpus scores

tests/attacks/ holds executable scenarios. Each one is run against the real sandbox and scored, and the number below is regenerated from that run rather than written by hand:

python scripts/attack_report.py --write

14 of 16 scenarios prevented (88%).

Scenario Link Expected Result
audit-replacement-is-detected 6 not_prevented known-gap ⚠
audit-truncation-is-detected 6 denied prevented
dns-tunnelled-exfiltration 5 denied prevented
egress-forge-without-opt-in 5 denied prevented
egress-lookalike-registry 5 denied prevented
egress-nonstandard-port 5 denied prevented
egress-raw-ip 5 denied prevented
egress-unlisted-host 5 denied prevented
exfil-over-tls-to-allowlisted-host 5 denied prevented
exfil-via-registry-during-install 5 not_prevented known-gap ⚠
fetch-phase-runs-no-lifecycle-scripts 3 denied prevented
git-dependency-still-installs 0 allowed allowed
harvest-host-credential-files 4 denied prevented
registry-token-theft-from-the-sandbox 4 denied prevented
registry-traffic-still-works 0 allowed allowed
shai-hulud-webhook-exfiltration 5 denied prevented

Counted as failures because they are:

  • audit-replacement-is-detected — Attacker replaces the log and re-anchors it with a chain of their own
  • exfil-via-registry-during-install — Payload uses the package registry itself as the exfiltration channel

The rate counts a disclosed gap as a failure, because it is one. It counts a scenario that could not run as a failure too. A corpus that only tallies the cases it wins produces a nicer number and a worse tool. A test in tests/test_attacks.py fails if this README stops matching what the corpus actually scores.

This number moves when the corpus grows, in both directions, and neither direction means what it looks like. It fell from 88% to 85% when two disclosed gaps started being counted against a larger set, and rose again when scenarios were added that blastgate prevents. A rate that can only rise is a curated one, and there is a test asserting that adding an honest gap lowers it.

Scenarios are drawn from documented incidents where one exists. The source: field carries the write-up, and scenarios with no verifiable source say constructed rather than borrowing a citation from an incident they only resemble.

What actually stops a payload

The proxy environment variables are configuration, and a payload has no reason to honour configuration. They are not the control.

The control is that the install container is joined to a network with no gateway. The only host it can open a socket to is the proxy, and the proxy is the only container joined to both networks. A payload that ignores HTTPS_PROXY entirely and dials registry.npmjs.org:443 directly finds no route to it. That difference — a setting versus a topology — is the one the tests are built around:

tests/test_end_to_end.py::TestTheWholeThing::test_a_payload_that_ignores_the_proxy_is_still_blocked

Git dependencies never open a forge

A dependency like "is-odd": "github:jonschlinkert/is-odd" used to require putting github.com in the allowlist for the whole install — and a proxy that does not intercept TLS cannot tell a clone of that repository from a push of your stolen token to an attacker's.

So the forge is not in the install's allowlist at all. Declared git dependencies are fetched first, by a separate phase running under blastgate/allowlists/npm-resolve.yaml, where forges are reachable, the registry is not, and no package lifecycle script executes. The install then reads them from a read-only local mirror.

blast run npm --allow git-dependencies -- npm install

--allow git-dependencies no longer opens a host to the install. It permits the resolve phase to run. The audit log from a real run shows the difference:

npm-resolve  ALLOW  github.com            ← fetched, before any package code ran
npm          ALLOW  registry.npmjs.org
npm          DENY   codeload.github.com   ← the install tried; it was refused

The install succeeds anyway, from the mirror. There is no read to distinguish from a write because there is no connection.

This works the same way for all three ecosystems. npm, pip and cargo all shell out to git, so the same mirror and the same rewriting serve all of them; only the spelling of a dependency differs between manifests. cargo needs one extra push, because it fetches with libgit2 which ignores url.insteadOf — CARGO_NET_GIT_FETCH_WITH_CLI makes it use the git CLI so the redirection applies.

It is a narrowing, not a solution. The registry has to stay reachable during an install, and a registry accepts writes — that residual is the corpus scenario exfil-via-registry-during-install, counted as a failure below. And an ecosystem added later has no parser until one is written, so it keeps the older arrangement and blast run warns when that path is taken.

Private registries: the token never enters the sandbox

Installing from a private registry normally means a token in ~/.npmrc, readable by every postinstall script that runs. That is not a hypothetical weakness — it is what the Shai-Hulud worm harvested, from that exact file, across several hundred packages.

echo -n "$NPM_TOKEN" | blast creds add registry.internal.example.com
blast run npm -- npm ci

The credential stays on the host. The sandbox talks plain HTTP to the broker over a network with no gateway, and the broker attaches the Authorization header on the upstream leg. A payload that reads every environment variable and every readable file inside the sandbox finds nothing — which is the corpus scenario registry-token-theft-from-the-sandbox, not a claim.

The secret is read from stdin, never an argument, because arguments are visible in ps. blast creds list prints hosts and withholds secrets. The store is mode 0600 and is refused outright if anything wider.

The broker forwards reads only. It is authenticated, so without that a payload could publish through it — which is precisely how Shai-Hulud spread, republishing every package the stolen token could reach. PUT, POST, DELETE and PATCH get 405.

This is not TLS interception. There is no CA, nothing to distribute or expire, and no TLS parser in the path of attacker-controlled bytes. What a payload retains is the ability to make authenticated reads during the install; what it cannot do is take the token anywhere.

Untested against a real private registry. The end-to-end test runs against the public npm registry, which accepts any bearer token on a public read. That proves the credential never enters the sandbox. It does not prove that Artifactory, Nexus, or a private npm scope accept the header shape blastgate sends. If it does not fit, the install fails with an authentication error rather than falling back to something less safe — but expect to be the first person to find out. Reports welcome.

Brokering is wired for npm and pypi. cargo needs registry source replacement rather than an index URL and is not done, so a cargo install gets no brokering rather than a setting that silently does nothing.

The audit log is written to a directory mounted into the proxy container and nowhere else, so the sandbox cannot read or delete it. Pointing --audit inside the project directory is refused, because the project is mounted writable and a log the payload can rewrite is not evidence.

After each run the host-side runner — a different process, which the sandbox cannot reach and which the proxy has never had mounted — records the log's head hash and entry count in a separate anchor store. Truncating a log no longer passes verification, and anchors are chained across runs so deleting a whole run is equally visible.

blast audit distinguishes two verdicts that a hash chain alone conflates:

OK: chain verified against anchor, 12 entries (anchored at 12 by run 4f2a91c0)
UNANCHORED: chain is internally consistent, 12 entries.
    No anchor found, so truncation cannot be detected.

Anchoring is not provenance. It raises the bar from writing one file to writing two consistently. An attacker with write access to both stores forges both, and that limit is asserted by a test so it cannot be quietly assumed closed.

Install

pip install blastgate

Python 3.10+ and a container runtime — Docker or Podman. The runtime is not optional and is not a packaging detail: the isolation is the container. There is no degraded mode that runs an install without one, because a sandbox that falls back to no sandbox is worse than no sandbox, and blast run refuses rather than pretending.

Run an install

blast run npm -- npm ci

The install command goes after --. Blastgate's own options go before it:

blast run npm --project ./app --allow git-dependencies -- npm install

Then review what it tried to reach:

blast audit ~/.blastgate/audit/<project>-<hash>.log

Every failure path refuses rather than falling back. A missing runtime, a network that is not actually internal, or a sidecar that does not come up all produce a refusal, never an unsandboxed install.

Try the policy engine

No Docker, no containers, nothing to configure.

blast check npm registry.npmjs.org
ALLOW registry.npmjs.org (matched: exact:registry.npmjs.org) - Primary package registry metadata and tarball host

Default is deny, and every allow path is explicit:

$ blast check npm evil.example.com
DENY evil.example.com - default deny: host not in allowlist

$ blast check npm cdn.npmjs.org
ALLOW cdn.npmjs.org (matched: wildcard:*.npmjs.org) - Registry CDN edge nodes and asset endpoints

$ blast check npm registry.npmjs.org.evil.com
DENY registry.npmjs.org.evil.com - default deny: host not in allowlist

$ blast check npm 127.0.0.1
DENY 127.0.0.1 - invalid hostname: Raw IP addresses are not allowed: 127.0.0.1

Hosts that are commonly needed but double as exfiltration channels are denied unless you opt in by name:

$ blast check npm github.com
DENY github.com - conditional host requires condition 'git-dependencies': Direct git repository dependencies and GitHub release assets

$ blast check npm github.com --allow git-dependencies
ALLOW github.com (matched: conditional:github.com) - Direct git repository dependencies and GitHub release assets

Exit code is 0 for allow, 1 for deny, 2 for an error. Allowlists ship in-repo under blastgate/allowlists/ — a remote fetch would itself be a supply chain dependency — and every entry carries a reason or it is rejected at load.

What it does not defend against

The full list is section 8 of the threat model, which is deliberately longer than the list of things it does defend against. The ones most likely to matter to you:

  • Container escape. Isolation will depend on the container runtime. A payload with a working escape reaches the host.
  • Exfiltration to an allowlisted host. No TLS interception, so a payload writing to an attacker's repository on an allowlisted forge is not blocked. This gap is real and was used in the 2026 campaigns.
  • DNS-based exfiltration. Not detected.
  • Post-install runtime. Protects the install, not the application later.
  • Compromised base image or package manager. Enforces nothing useful.
  • Ports are not policy. blast check npm registry.npmjs.org:8443 reports ALLOW because policy decides on hostname alone. The proxy restricts tunnelling to 443, so the two disagree when inspected separately.
  • Your project must live where the runtime can see it. On macOS the runtime runs in a VM sharing only part of the filesystem; a project outside a shared path cannot be mounted.

The rule

Every protection claim maps to a test that fails when the control is removed. A control with no test is a claim, not a protection, and stays in the gaps list until it has one. If a change alters what blastgate protects against, the threat model changes in the same commit.

That is also why the threat model is the first commit in this repository and the code is the third.

Tests

pip install -e ".[test]" && pytest

The evasion cases in tests/test_policy.py are the highest-value tests here and back every claim in section 6 of the threat model.

License

Apache 2.0. See LICENSE.

Contributing

The most useful contribution is an attack scenario drawn from a real incident — ten of sixteen in the corpus are still marked constructed, meaning they were written from the threat model rather than from something that happened.

Allowlist changes are the highest-risk patch here and are reviewed as such. See CONTRIBUTING.md; if a host is only needed by your project, .blastgate.yaml handles it without a PR.

Security reports go through private disclosure, not public issues. Note that section 8 of the threat model already discloses several things that look alarming — but if a disclosure there is wrong or undersells a gap, that is a defect worth reporting.

License

Apache 2.0. See LICENSE.

Metadata

Release files for blastgate 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for blastgate 0.1.0
File Size Uploaded
blastgate-0.1.0.tar.gz 103.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for blastgate 0.1.0
File Interpreter ABI Platform
blastgate-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 171.4 kB

Release files / blastgate-0.1.0.tar.gz

Download URL blastgate-0.1.0.tar.gz
Size 103.1 kB
Tags Source
SHA-256 checksum
How to use checksums
05fdded8906e19a261a82e4ea07277c4ace1b590c7d1c825233fa5a972ed0553
BLAKE2b-256 checksum
How to use checksums
4a5ba4e999c60568521a8e251e1b61316b16b3d20bb3210526a8ad58f9564f0c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 26, 2026.

Transparency log

Release files / blastgate-0.1.0-py3-none-any.whl

Download URL blastgate-0.1.0-py3-none-any.whl
Size 68.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
1f74e8c5f6670da88b6a47a680025f573d3654cb9bc0c5413a7b64ee3b9afc7c
BLAKE2b-256 checksum
How to use checksums
b041d21311a97c006587c18efecfe8e11f98f246aeedffe087f2e45188ba5a14
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 26, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page