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 ownexfil-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:8443reports 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)
| File | Size | Uploaded | |
|---|---|---|---|
| blastgate-0.1.0.tar.gz | 103.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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