[sandbox audit] Pin, verify and attest-ready the published server binary - #24
Draft
Wauplin wants to merge 1 commit into
Draft
[sandbox audit] Pin, verify and attest-ready the published server binary#24Wauplin wants to merge 1 commit into
Wauplin wants to merge 1 commit into
Conversation
Wauplin
force-pushed
the
security/pin-and-verify-binary
branch
from
September 8, 2026 14:47
8154b78 to
a101b39
Compare
Every sandbox job downloads `sbx-server` from a public bucket, `chmod +x`es it and runs it as root, as PID 1, holding the derived sandbox token. Two things about that were unnecessarily weak. **The object every client consumes is mutable, and the pipeline that writes it was loose.** The workflow published an immutable `sbx-server-<commit>` copy -- good -- and then overwrote the `sbx-server` alias that every client actually fetches. It was `workflow_dispatch` against whatever `main` said at that moment, used mutable action tags, and installed the `hf` CLI with `curl -LsSf https://hf.co/cli/install.sh | bash` *in the job holding the bucket-write OIDC token*. That last one is the sharpest edge here: an unpinned remote script running with publish rights over every sandbox of every user. No compromise is suspected -- the published bytes match this source -- the point is that nothing guaranteed the next fetch. **`/health` reported `version`, and nothing negotiated it.** `version` moves for a doc fix as readily as for a protocol break, so a client cannot decide from it whether talking to a server is safe. A pool host keeps the binary it downloaded at boot for up to 24h, so a client and a server from different releases meet in production routinely -- and the recent per-sandbox capability token is exactly the kind of change that fails quietly, as a 403 on some later route. What changed: - `/health` reports `protocol` (an integer, currently 2; 1 was the pre-per-sandbox-token contract). It moves only when the wire contract changes in a way a client can be wrong about. The payload moved into its own `health()` so the handshake can be asserted on without a socket. - `publish.yml` publishes `sbx-server-<sha256>` plus a manifest (`{version, commit, sha256, target, size}`) as the canonical content-addressed names. The commit-labelled copy and the `sbx-server` alias are still written, as server-side copies of the digest object, so every name in the bucket is byte-identical by construction and existing clients keep working. - The workflow triggers on a `v*` tag, so the published bytes always correspond to a named, reviewed commit. `workflow_dispatch` stays as an emergency path but now requires the ref to be named. - `actions/*` pinned by commit SHA with Dependabot's `# vX.Y.Z` comment. - The `hf` CLI is installed from PyPI at a pinned version in a venv instead of piping a remote script into bash. - `cargo build --locked`, so `Cargo.lock` is authoritative rather than re-resolved at publish time. - Build and publish are separate jobs: the build has no `id-token` permission and no environment, so nothing running during compilation -- a build script, a proc macro, a dependency -- shares a job with the bucket-write credential. The digest is recomputed from the bytes the publish job is about to upload rather than trusted across the boundary. - `environment: production`, so the alias update can be gated on reviewers and the bucket-write OIDC scope can be restricted to it. - README gains a "Releasing" section: pinning the digest in the client couples the two repos, and that trade needs to be written down rather than learned. Not done here: signing and build provenance attestation (`actions/attest-build-provenance` or cosign) over the digest, and client-side signature verification. The pinned digest is what closes the gap for now, because it is baked into a reviewed client release; a signature is the follow-up. Validation: - `cargo test`: 39 passed, including a new case asserting `/health` reports `protocol` as an integer matching `PROTOCOL` -- dropping or renaming that field turns the client's check into a silent no-op. - `cargo build --locked --release --target x86_64-unknown-linux-musl` succeeds; the resulting binary reports `sbx-server 0.6.0 listening on 0.0.0.0:49912 (mode: dedicated, ...)`. - `actionlint 1.7.12` on `publish.yml`: clean. - The bootstrap that consumes this (huggingface_hub side) was exercised against a local HTTP server across 14 image/tool shapes; see that PR. Not validated: the workflow has not been run. It needs a dry run on a branch with the alias-update step disabled, and the `production` environment plus the narrowed OIDC scope have to be created before the first real publish. Companion client change: huggingface_hub branch `security/pin-and-verify-binary` (linked from the PR description). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wauplin
force-pushed
the
security/pin-and-verify-binary
branch
from
September 8, 2026 14:56
a101b39 to
7ec6f29
Compare
Wauplin
changed the base branch from
security/harden-http-transport
to
security/bound-server-resources
September 8, 2026 14:58
This was referenced Sep 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Companion client PR: huggingface/huggingface_hub#4837 — these two have to land together, and the client cannot merge until this workflow has published once (see "Rollout" below).
What was wrong
Every sandbox job downloads
sbx-serverfrom a public bucket,chmod +xes it and runs it as root, as PID 1, holding the derived sandbox token. Two things about that pipeline were weaker than they needed to be.The object every client consumes is mutable, and the pipeline that writes it was loose.
publish.ymlpublished an immutablesbx-server-<commit>copy — good — and then overwrote thesbx-serveralias that every client actually fetches. Around that:on: workflow_dispatchagainstref: main, so the published bytes were whatevermainhappened to say at dispatch time — not a named, reviewed commit.actions/checkout@v7, a mutable tag.curl -LsSf https://hf.co/cli/install.sh | bash, in the job holding the bucket-write OIDC token. That is the sharpest edge in the file: an unpinned remote script running with publish rights over every sandbox of every user.cargo build --releasewith no--locked, so dependency versions were re-resolved at publish time rather than taken from the reviewedCargo.lock.Nothing suggests any of this was exploited — the published binary matches this source. The finding is the absence of a guarantee about the next fetch, and the blast radius: a single write to that bucket path reaches every sandbox of every user.
/healthreportedversion, and nothing negotiated it.versionmoves for a doc fix as readily as for a protocol break, so a client cannot decide from it whether talking to a given server is safe. That matters because a pool host keeps the binary it downloaded at boot for up to 24h: a client and a server from different releases meet in production routinely. The per-sandbox capability token from #18 is exactly the kind of change that fails quietly under that — as a 403 on an unrelated route, several calls later.What changed
Protocol negotiation.
/healthnow reportsprotocol, an integer, currently2(1was the pre-per-sandbox-token contract). It moves only when the wire contract changes in a way a client can be wrong about — never for an additive one. The payload moved into its ownhealth()so the handshake can be asserted on without a socket.Content-addressed publishing. The workflow now publishes, per build:
sbx-server-<sha256>sbx-server-<sha256>.json{version, commit, sha256, target, size}sbx-server-<commit>sbx-serverThe last two are remote copies of the digest object, so every name in the bucket is byte-identical to the digest by construction, and existing clients keep working unchanged. The digest is printed in the run summary in a form that can be pasted straight into the client.
Workflow hardening.
v*tag, so the published bytes always correspond to a named, reviewed commit.workflow_dispatchstays as an emergency path but now requires arefinput — neither trigger builds "whatevermainsays right now".actions/*pinned by commit SHA with Dependabot's# vX.Y.Zcomment convention, so updates stay automated.hfCLI is installed from PyPI at a pinned version into a venv, instead of piping a remote script into bash.cargo build --locked.id-tokenpermission and no environment, so nothing running during compilation — a build script, a proc macro, a dependency — shares a job with the bucket-write credential. The digest is recomputed from the bytes the publish job is about to upload rather than trusted across the job boundary. This is the achievable form of the "build with no network beyond the crates registry" idea; real egress control would need a third-party action, which felt like the wrong thing to add to a supply-chain PR.environment: production, so the alias update can be gated on reviewers and the bucket-write OIDC scope can be narrowed to it.README gains a "Releasing" section. Pinning the digest in the client couples the two repos — every server release now needs a client release — and that trade needs to be written down rather than learned.
Validation
cargo test:The new case asserts
/healthreportsprotocolas an integer equal toPROTOCOL. Dropping or renaming that field would turn the client's check into a silent no-op, which is the exact failure it exists to prevent.cargo build --locked --release --target x86_64-unknown-linux-muslsucceeds, and the resulting binary runs:actionlint 1.7.12onpublish.yml: clean, exit 0.The bootstrap that consumes all of this was exercised end to end against a local
python3 -m http.serveracross 14 image/tool shapes — see the client PR for the table.Not validated
productionenvironment exists and the bucket-write OIDC scope is pointed at it.Sandbox.create()under a namespace we control is still owed — that lives on the client PR.Rollout
The two repos are now coupled, in this order:
v0.6.1(or whatever the release is) and let the workflow publish. Grab the digest from the run summary.SANDBOX_SERVER_SHA256on the client PR, then merge it.Between 1 and 3 nothing changes for users: the alias keeps being updated and no client pins a digest yet. The client PR currently pins a local musl build of this branch, which does not exist in the bucket — so it must not merge before step 2.
Not in this PR
Signing and attestation. An
actions/attest-build-provenance(or cosign) signature over the digest, and client-side signature verification, is the natural next step. The pinned digest is what actually closes the gap for now, because it is baked into a reviewed client release; a signature adds provenance tied to the commit, and needs verification tooling available inside arbitrary sandbox images to be worth much. Follow-up.Crate version. I left
Cargo.tomlat0.6.0. Adding a field to/healthis additive andPROTOCOLstays2(the contract #18 introduced), so nothing incompatible happened here — but if the team's convention is to bump on any wire change, say so and I will.Adjacent, not touched:
ci.ymlstill uses mutable action tags (actions/checkout@v7,actions/cache@v4; #13 is bumping the latter) and still builds the gnu target while this workflow ships musl. Both belong to other PRs in this series.