Reusable detonation framework: run untrusted documents through disposable, hardened workers
Project description
blastbox
A reusable framework for running untrusted/malicious documents through disposable, hardened workers — and turning their output into a typed, host-validated result you can trust.
Extracted from the common substrate of two production services (a LibreOffice document→image rasterizer and a Tika recursive-extraction service) that had each grown a parallel, drifting copy of the same machinery. blastbox is the single, audited-once core; each service becomes a thin engine.
- Write one function. An engine implements
detonate(input, outdir, limits) -> DetonationResult. The framework gives it ingress, a JobStore, a disposable hardened worker per job, output-trust validation, artifact serving, optional warm pooling, metrics, and a CLI — for free. - Output is never trusted. A worker processes a malicious document; the host re-seals its output from disk (recomputing hashes/sizes, confining paths) before believing a byte of it.
- One untrusted doc per disposable slot — always. Warm pooling pre-pays startup in the background; it never reuses a worker across documents.
- Typed, engine-shaped output. A shared node library (
Page,EmbeddedResource,ExtractedText, a genericRecordfloor, recursive) lets engines be as specific or generic as they need, while the framework validates a fixed security envelope identically for everyone.
Proven end-to-end against two real, language-diverse engines: a Python + LibreOffice rasterizer and a JVM + Tika recursive extractor.
Architecture
blastbox/
├── contract/ typed node tree + security envelope + seal/validate (registry-aware at any depth)
├── host/ LAYER 1 — host orchestrator (engine-agnostic) — needs blastbox[host]
│ ├── ingress FastAPI API + CLI: upload, status, artifact serving, /metrics
│ ├── jobs/ JobStore protocol + memory / sql / redis backends + retention
│ ├── dispatch claim → launch disposable worker → validate output → serve (+ warm path)
│ ├── runtime/ backend select (fail-closed): hardened `docker run` (runc/runsc)
│ │ OR a Firecracker microVM per slot (AF_VSOCK warm protocol; the
│ │ engine defines warmup — a JVM engine via CRaC, with guest-console
│ │ CPU-feature-mismatch detect + a probe; a non-JVM engine without)
│ │ OR a disposable libvirt/KVM VM per job (full-OS engines; vm_compose
│ │ spec, assign-enforce IP via nwfilter, per-IP egress rooter)
│ ├── pool warm slot pool (one-doc-per-slot, never-reuse; burst + health loops)
│ ├── trust output-trust validator — re-seals worker output from disk
│ ├── netpolicy opt-in egress OVERLAY: personality → exit driver, fail-closed to `none`
│ ├── netd privileged rooter: per-worker pcap + netns wiring + non-TCP leak guard (v4+v6)
│ └── observability
├── worker/ LAYER 2 — worker SDK (runs inside the disposable worker) — lean core
│ ├── engine the seam: detect / warmup / detonate
│ ├── harness read input → detonate → seal → write metadata.json (+ egress-readiness barrier)
│ ├── sandbox/ auto-selected in-process hardening: nsjail / bwrap / nono / container
│ │ (BLASTBOX_SANDBOX override; container inside OCI; nono = Landlock, no userns)
│ ├── warm service lifecycle: boot → warmup → one job → exit
│ └── fc_warm / fc_guest Firecracker guest: AF_VSOCK control plane + warm protocol
└── engines/ reference EXAMPLES only (detonate, urlgrab) — domain engines live in their own repos
The host never imports an engine; it depends only on the contract. An engine never handles
hashes/paths defensively; the worker SDK and host do that in audited code. blastbox ships no domain
engines — engines/ holds runnable examples (detonate, urlgrab); real engines (ClippyShot,
RedTusk, …) live in their own repos and plug in via BLASTBOX_ENGINE=module:Class.
Writing an engine
from pathlib import Path
from blastbox import Engine, DetonationResult, run_detonation
from blastbox.contract import Page, DeclaredArtifact, Detection, ArtifactRef, Dimensions
from blastbox.limits import Limits
class MyEngine:
name = "myengine"
formats = frozenset({"pdf"})
def detonate(self, input: Path, outdir: Path, limits: Limits) -> DetonationResult:
# ... render/extract; write artifact files into outdir ...
(outdir / "page-001.png").write_bytes(png_bytes)
return DetonationResult(
payload=Page(index=0, dims=Dimensions(width=210, height=297, unit="mm"),
image=ArtifactRef(id="p0")),
artifacts=[DeclaredArtifact(id="p0", path="page-001.png", kind="image")],
detected=Detection(label="pdf", mime="application/pdf", confidence=1.0, source="myengine"),
)
# worker entrypoint:
if __name__ == "__main__":
import sys
from blastbox.worker.harness import main
sys.exit(main(MyEngine()))
The harness seals your declared artifacts (recomputing sha256/size from disk, confining paths,
resolving references) and writes metadata.json. The host's validate_worker_output re-validates it.
Complex engines build recursive EmbeddedResource trees (e.g. Tika's recursive metadata); simple
ones use Page/Record. Engine-specific node subtypes register via contract.register_node_type.
Install
pip install blastbox # lean core — everything an ENGINE needs (pydantic only)
pip install blastbox[host] # + the host orchestrator (FastAPI, jobstores, observability)
Run (host)
# serve + dispatch are SEPARATE processes — point both at a SHARED job store:
export BLASTBOX_DATABASE_URL=sqlite:////var/lib/blastbox/jobs.db # or postgresql://… / redis://…
blastbox serve --host 127.0.0.1 --port 8000 # the ingress API
blastbox dispatch # the worker dispatcher loop
BLASTBOX_DATABASE_URLis required for the two-process flow above. Unset, each command uses its own in-memory store, so jobs submitted toserveare invisible todispatch(a warning is logged).sqlite:///…,postgresql://…, andredis://…are supported.
POST /v1/jobs (multipart file + engine) enqueues a job; the dispatcher launches a hardened
disposable worker for it; GET /v1/jobs/{id}/artifacts/{artifact_id} serves validated output.
The defaults run a secure single-host deployment — you set almost nothing. For the warm-pool /
runtime / sandbox tiers, docs/DEPLOYMENT.md is the tier-decision guide
(incl. the per-tier capability matrix) and docs/CONFIGURATION.md is the
full BLASTBOX_* reference.
Testing
python3 -m venv .venv && .venv/bin/pip install -e .[dev]
.venv/bin/pytest tests # unit (mocked) — runs anywhere
.venv/bin/ruff check src tests && .venv/bin/mypy src
The gated integration suite (gVisor C/R + Firecracker warm round-trips, real sandboxes) needs
real runtimes + (on Ubuntu 24.04+) root, because the hardened kernel restricts the
unprivileged user namespaces runsc/bwrap/nsjail need. See docs/TESTING.md
for the full setup (incl. building a probe-engine rootfs and the userns workaround).
Security model
- Disposable worker per job:
--network=none --cap-drop=ALL --no-new-privileges --read-only; the input is deleted after conversion. No network is the default — egress is an explicit opt-in (see Network overlay below) and is fail-closed at every layer. - runsc (gVisor) is required by default — fail-closed. If no secure runtime is available the
dispatcher refuses the job early with an actionable
InsecureRuntimeRefused(rather than letting the worker start and fail its own sandbox self-check opaquely). To run without gVisor, the operator must explicitly opt in withBLASTBOX_ALLOW_RUNC=1— that runs the worker under plainruncin deliberate degraded mode (no gVisor isolation; the per-job warning is recorded, and the worker is told to run its self-check leniently).BLASTBOX_REQUIRE_SECURE_RUNTIME=1is a hard lockdown that refusesrunceven whenBLASTBOX_ALLOW_RUNCis set. (BLASTBOX_WORKER_RUNTIME=runcforces the runtime but still needs theALLOW_RUNCconsent flag.) - The host re-seals worker output from disk — worker-reported hashes/sizes are never trusted; artifact
paths are confined; the input-SHA round-trip is checked;
metadata.jsonmust be a regular file. - Ingress rejects oversized bodies before spooling, sanitizes filenames, serves artifacts by id under a confined path, and (optionally) sits behind a bearer token / auth proxy.
- The contract bounds payload size/depth and validates every node; engine subtypes are validated against their registered schema.
Network overlay (opt-in egress)
Most detonation is done sealed (--network=none). But some analysis needs the network — a URL
fetch, a sample that only behaves when it can call home. blastbox exposes a safe, opt-in egress
overlay that any engine can choose to use, the same way across every sandbox type (runc /
gVisor / Firecracker microVM). The overlay is a capability the framework provides; the engine just
declares it wants an exit.
Fail-closed by default, at every layer. A worker cannot grant itself the network:
- The effective egress is a named personality (
BLASTBOX_NETPOLICY_<NAME>='exit=…,k=v'). Resolution order is per-job override (only whenBLASTBOX_ALLOW_NETPOLICY_OVERRIDE=1and the name is declared) → per-engine default →none. An unknown name at any step collapses tonone;noneis reserved and cannot be redefined to grant egress. - The disposable worker attaches
--network=noneunless a personality with a real exit resolved; the in-process sandbox keeps its netns unshared unless egress was explicitly granted. - To get any egress, all of these must line up: an operator declares a personality and stands up the exit infra and an engine opts in (or a job overrides, with the gate on).
netd — a small privileged host rooter (the analogue of CAPE's rooter, now containerized and
cap-drop=ALL + minimal caps, reaching Docker through a read-only socket-proxy) — watches container
events and, per opted-in worker: captures its traffic to a per-job pcap, wires its network namespace
to the chosen exit, and installs a non-TCP leak guard (an in-netns iptables/ip6tables OUTPUT
firewall) so a sample can't punch UDP/ICMP/raw or IPv6 out past a TCP-only tier.
Per-personality egress hardening — two composable knobs, declared in the
BLASTBOX_NETPOLICY_<NAME> entry, for the netd-gated tiers tor / openvpn / wireguard:
egress_ports=53 80 443— a web-only L4 allowlist (DNS/HTTP/HTTPS): drop every other TCP port and all non-TCP (UDP:53 only when 53 is itself listed), fail-closed via a catch-all DROP.block_internal=1— drop all RFC1918 + link-local/metadata destinations: no SSRF into the host LAN, no169.254.169.254, no lateral movement to sibling workers (non-internal UDP/ICMP preserved).
netd folds both into the in-netns leak guard — a precondition for wiring, so they are
fail-closed (no guard ⇒ no egress). The restriction to those tiers is deliberate: there the worker's
own OUTPUT carries the real destination IP:port (so the filter is meaningful) and egress only exists
once netd has wired it. A SOCKS/httpproxy proxy hop would have its own tunnel dropped, and a plain
bridge (direct/inetsim) would fail open under the gVisor default runtime — both are refused
with a clear diagnostic. Proven live (toolz3): web-only via VPN, internal blocked.
Egress methodologies (all exposed to any engine, all fail-closed):
| Exit | What it does | Credentials |
|---|---|---|
inetsim (fakenet) |
sinkhole — a FakeNet-NG sidecar answers everything | none — example |
tor |
transparent CAPE recipe (TransPort/DNSPort) or a country-pinned SocksPort fleet | none — example |
socks |
transparent tunnel through any SOCKS5 exit (tun2socks) | bring-your-own provider |
httpproxy |
HTTP(S)_PROXY through any chaining proxy sidecar | bring-your-own provider |
openvpn / wireguard |
all-IP path via a VPN+NAT gateway sidecar | bring-your-own provider |
inspect |
route through a TLS-MITM gateway; export keys → decrypt the capture | (layered on an exit) |
tor and fakenet need no accounts and no credentials, so they are the worked examples; the others
are provider-agnostic mechanism (bring your own SOCKS/HTTP-proxy/VPN — blastbox ships no provider
secrets). The reference engine blastbox.engines.urlgrab (fetch one URL, seal the response — it
does not render or execute) is the example consumer used to demonstrate the overlay end-to-end:
net_policy=fakenet proves the whole pipe safely, net_policy=tor fetches attribution-protected.
Status
Core framework complete and adversarially tested: contract + full host orchestrator + worker SDK (harness, sandbox self-check, warm protocol) + warm pool (burst + health loops) + warm dispatch.
A family of runtime backends behind one SlotRuntime seam, all fail-closed and config-selected
(BLASTBOX_POOL_RUNTIME, no code changes to switch): local — hardened docker run (runc/runsc), a
Firecracker microVM per slot (AF_VSOCK warm protocol — the engine defines what "warmup" means, so
the tier is engine-agnostic), and gVisor checkpoint/restore; and network-endpoint — a fixed fleet
of boxes you own (static) or disposable cloud workers (aws-ec2 / aws-lambda-microvm), where any
engine runs off-box via python -m blastbox.worker.http_agent over a generic HTTP+tar transport (same
sealed-envelope contract). A cascade composes same-transport tiers into one pool — e.g. X warm on
your own hardware, overflow to AWS (BLASTBOX_POOL_TIERS=static:8,aws-ec2:16); all tiers must share one
dispatch style (all network-endpoint, or all file-handshake — a mix fails fast). Four in-process sandbox backends, auto-selected (nsjail → bwrap → nono →
container; container inside an OCI host) with a BLASTBOX_SANDBOX override. nono is a Landlock
capability sandbox — filesystem + network containment without user namespaces, for hosts where
bwrap/nsjail can't run (restricted-userns / no CAP_SYS_ADMIN); it's secure=False (no
seccomp/namespaces) and so an explicit opt-in, at ~1–4 % per-job overhead.
Warmup is per-engine: the JVM/Tika engine warms via CRaC (checkpoint/restore), and for that path
the Firecracker runtime adds guest-console CPU-feature-mismatch detection + a one-shot compatibility
probe. The LibreOffice
engine has no CRaC (it isn't a JVM): instead its ~750 ms soffice boot is hidden by the warm-UNO
FC snapshot/restore tier (below), or it falls back to cold-starting soffice --convert-to per job.
Proven end-to-end — live on a Firecracker host — with two real, language-diverse engines, both in the
FC warm-pool tier: a JVM + Tika recursive extractor (CRaC warm-restore) and a Python +
LibreOffice rasterizer (warm-UNO snapshot/restore, or cold-boot; deploy/firecracker/Dockerfile.clippyshot).
The LibreOffice engine also runs standalone on the docker + sandbox tier (runc/runsc).
Disposable libvirt/KVM VM tier — for full-OS engines. Beyond the two auto-selected
container/microVM runtimes, blastbox ships a libvirt VM-worker primitive
(host/runtime/libvirt_vm.py + vm_compose + VmJobDispatcher) for engines that need a whole
guest OS per job — e.g. a Windows code-signature validator that detonates inside a real Windows VM.
Unlike the FC/gVisor warm tiers (selected by BLASTBOX_POOL_RUNTIME), this tier is library-wired:
a consuming app builds a VmWorkerSpec (golden image, warm size, agent port, egress) and gets
warm-pooling and per-job disposability for free (golden rotation is a separate helper the app
schedules — bake a fresh golden, then flip the spec). Workers are
IP-assigned-and-enforced — a deterministic MAC+IP is reserved per worker and pinned via a libvirt
clean-traffic nwfilter (worker_ip_pool), so a root-compromised guest can't re-IP around the
per-worker egress rooter (LibvirtEgress, a CAPE-style per-IP iptables BBVM_<ip> chain). The
DHCP-learning fallback restricts DHCPSERVER to the trusted bridge so a worker can't rogue-DHCP a
different lease. Proven live with the win-validator Authenticode engine.
Warm-UNO via FC snapshot/restore — shipped. A microVM with a running, idle in-guest unoserver
(soffice --accept on a local UDS) is captured in a Firecracker memory snapshot built first-boot on the
host, then restored→converted→destroyed per job — the "FC but not CRaC" warm route that hides the
~750 ms soffice boot. Validated end-to-end on a Firecracker host: snapshot a live unoserver → restore
from /dev/shm → convert → output is pixel-identical to cold --convert-to across calc/csv and
impress/draw (pptx / odp / ppt / odg). Measured: a warm restore (~0.5 s; the FC load-snapshot
primitive itself is ~4 ms) replaces a ~7.8 s cold boot+warmup — ~13.5× to a ready slot — with the
copy-on-write mem base optionally pinned in RAM (BLASTBOX_SNAPSHOT_MEM_TMPFS, a per-host toggle).
Everything UNO stays in-guest; only the existing vsock + output-disk channels cross the boundary. Gated
opt-in: BLASTBOX_POOL_RUNTIME=firecracker + BLASTBOX_POOL_WARM_SNAPSHOT=1; bare-metal
bwrap/nsjail/nono stays cold. Implemented in host/runtime/fc_snapshot*.py
(SnapshotManager + SnapshotSlotRuntime); design:
docs/specs/2026-06-03-warm-uno-fc-snapshot-design.md.
Benchmarking — blastbox bench. A runtime-agnostic perf harness (stats + percentiles + A/B
comparisons) with a scenario registry that skips cleanly when prerequisites are absent, plus a
perf-marked CI ratio gate. blastbox bench --list; scenarios include sandbox.overhead (nono vs
bwrap vs none), snapshot.restore-latency, and convert.latency. Design:
docs/specs/2026-06-04-blastbox-bench-design.md.
License
MIT.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file blastbox-0.1.24.tar.gz.
File metadata
- Download URL: blastbox-0.1.24.tar.gz
- Upload date:
- Size: 448.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
32037d94aab917f4afdbe97b030b6707de3c01e365cedc0a0efa5ef178d41a84
|
|
| MD5 |
602b28b02f8e7222aa6efb01ef68e604
|
|
| BLAKE2b-256 |
03fd67d7332584e0ea789354e817c6f2014a527992e512c5bcc950043a58e48b
|
Provenance
The following attestation bundles were made for blastbox-0.1.24.tar.gz:
Publisher:
publish.yml on wmetcalf/blastbox
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
blastbox-0.1.24.tar.gz -
Subject digest:
32037d94aab917f4afdbe97b030b6707de3c01e365cedc0a0efa5ef178d41a84 - Sigstore transparency entry: 2206807097
- Sigstore integration time:
-
Permalink:
wmetcalf/blastbox@728986e4a32af3e9acfb6010fe0439e941ab3916 -
Branch / Tag:
refs/tags/v0.1.24 - Owner: https://github.com/wmetcalf
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@728986e4a32af3e9acfb6010fe0439e941ab3916 -
Trigger Event:
release
-
Statement type:
File details
Details for the file blastbox-0.1.24-py3-none-any.whl.
File metadata
- Download URL: blastbox-0.1.24-py3-none-any.whl
- Upload date:
- Size: 494.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3a08c1aed5fab75d1b5775646a30688ed5f0d72b0696e37ec960d5c1f6d97eba
|
|
| MD5 |
77e9a3cd9c98055c377c5d26bbd5446f
|
|
| BLAKE2b-256 |
33388f93330c02a5a95b8c5329393f1a2d71df89ca33831266e8fbdceb23d407
|
Provenance
The following attestation bundles were made for blastbox-0.1.24-py3-none-any.whl:
Publisher:
publish.yml on wmetcalf/blastbox
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
blastbox-0.1.24-py3-none-any.whl -
Subject digest:
3a08c1aed5fab75d1b5775646a30688ed5f0d72b0696e37ec960d5c1f6d97eba - Sigstore transparency entry: 2206807109
- Sigstore integration time:
-
Permalink:
wmetcalf/blastbox@728986e4a32af3e9acfb6010fe0439e941ab3916 -
Branch / Tag:
refs/tags/v0.1.24 - Owner: https://github.com/wmetcalf
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@728986e4a32af3e9acfb6010fe0439e941ab3916 -
Trigger Event:
release
-
Statement type: