Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/provenance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
# order both change the bytes -- one commit gave a review lane three checksums on one day. So
# the tarball is built by tools/release_tarball.sh, which makes the bytes canonical by
# construction (`git archive` at the ref with tar.umask=022: every file 644, only tracked
# files; globs in mix.exs's `files:` for one entry order), and the publisher builds AND
# publishes with the same script (`--publish`). A release published any other way will not
# files; one Hex version, tools/hex-version, installed below and refused otherwise by the
# script, because Hex 2.5.2 and later sort the tarball's entries where 2.5.1 kept the globs'
# order), and the publisher builds AND publishes with the same script (`--publish`). A release published any other way will not
# match this attestation, and the last step below says so, loudly, on the tag's run.
#
# THE OWNER TAGS AND PUBLISHES; THIS WORKFLOW ATTESTS. It never runs `mix hex.publish`. The
Expand All @@ -27,7 +28,7 @@
#
# THE SBOM (from 0.10.0). tools/sbom.sh generates a CycloneDX 1.6 SBOM of the runtime
# dependency set from the same tree, outside mix.exs (the EEF's mix_sbom binary, pinned by
# version and digest; one named workaround, G-087), and actions/attest binds it to the
# version and digest; no workaround since mix_sbom 0.12.0), and actions/attest binds it to the
# tarball's digest as a second attestation (predicate https://cyclonedx.org/bom). A consumer
# verifies it with `gh attestation verify <tarball> --predicate-type https://cyclonedx.org/bom`
# and fetches it with `gh attestation download` (docs/provenance.md).
Expand Down Expand Up @@ -70,6 +71,11 @@ jobs:
with:
otp-version: "28"
elixir-version: "1.18"
# The Hex the publisher builds with, from the one file both read (tools/release_tarball.sh
# refuses any other): setup-beam installs the newest Hex, and Hex 2.5.2 changed the
# tarball's bytes for the same commit.
- name: the pinned Hex (tools/hex-version)
run: mix local.hex "$(tr -d '[:space:]' < tools/hex-version)" --force
- name: the tarball, built the canonical way, its version and its sha256 (hex.pm's package checksum)
id: build
run: |
Expand Down
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,49 @@ All notable changes to this project are documented here. The format follows

## [Unreleased]

### Changed: a request the HTTP adapter rejects is the adapter's to answer

- An exception raised inside a call this transport makes into the adapter (reading the
body, sending the response, asking the protocol) is handed back to the adapter unchanged,
so its own pipeline answers it. Before, one carrying no `:plug_status` was answered as a
fault of this package: `500`, `-32603` "Internal error", and an error-level stacktrace in
the host's log. **How to tell whether you are affected:** a host on `Bandit` 1.13 or later
serving HTTP/2. `Bandit` 1.13 rejects a mid-body HTTP/2 HEADERS frame without END_STREAM
as malformed (RFC 9113, 8.1) and now resets the stream with `PROTOCOL_ERROR`, logged by
`Bandit`'s own `log_protocol_errors` setting, where 0.10.1 answered `500`. Host code's own
exceptions are unchanged: still `-32603` inside the envelope, with the request's id. The
send counts as the adapter's call including the `before_send` callbacks that plugs ahead of
this one registered, which Plug runs inside `send_resp`: an exception from one of them now
reaches the adapter's error handling, as it would in any Plug stack, rather than this
package's `-32603`. A client that resets the connection before the answer is sent no longer
leaves error lines from this package in the host's log.
- The adapter's read timeout is still answered by this package as its `408` JSON-RPC
refusal: the whole-body deadline is this package's.

### Security: Bandit 1.13 or later

- The optional requirement moves from `{:bandit, "~> 1.5"}` to `{:bandit, "~> 1.13"}`, for
EEF-CVE-2026-105659 (HTTP/2 blocked-send deadlines reset on every WINDOW_UPDATE) and
EEF-CVE-2026-105660 (an HTTP/1 request target choosing `conn.host` and `conn.port`), both
fixed in `Bandit` 1.13.0. A host that has `Bandit` beside this package now resolves a fixed
one; a host on the stdio transport alone, or on another adapter, is unaffected (the
dependency stays optional). **If `mix deps.get` reports a conflict,** your own requirement
holds `Bandit` below 1.13: raise it to `"~> 1.13"`. `Bandit` 1.13 requires `plug` 1.18 or
later, `thousand_island` 1.6 and `hpax` 1.1, and Elixir 1.13 or later (this package's own
floor, 1.17, is above it). Its other changes a host may see: HTTP/1.1 requests without a
valid `Host` are refused, and idle connections that send nothing are closed without a `408`
(its CHANGELOG). This package reads the `Origin` header for its own check, never
`conn.host`; an `authorize/1` that reads `conn.host` or `conn.port` needed the fix.
- This repository's own lock moves `bandit` 1.12.5 to 1.13.1, with `thousand_island` 1.6.0
and `hpax` 1.1.0.

### Added: CVE IDs for 0.10.1's advisories

- The Erlang Ecosystem Foundation's CNA assigned two of 0.10.1's CVEs:
CVE-2026-104634 is GHSA-wv7p-j6qh-3hj4 ("JSON `true`, `false` and `null` reach dispatch as
themselves"), and CVE-2026-88257 is GHSA-mrg2-4747-fmpw ("the advertised schema is enforced
at every depth"). GHSA-rq49-3p32-pggg and GHSA-x58h-5gfm-x4p8 have no CVE ID yet.

### Changed: CVE IDs come from the EEF CNA (SECURITY.md)

- `SECURITY.md` said GitHub assigns this package's CVEs. GitHub is a CNA but does not assign
Expand Down
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,12 +360,13 @@ the connection is clean, and an ordinary response is possible.
Measured through a real `Bandit` listener: `408` at 300, 301, 327 ms for a 300 ms deadline
and 1,500, 1,500, 1,501 ms for 1,500 ms; over HTTP/2 a twenty-frame drip answered at the
deadline. One residue is the adapter's, stated on the threat model's row with its cost: over
HTTP/2 a stream kept open by control frames alone (a WINDOW_UPDATE, or a HEADERS without
END_STREAM) is held past the deadline by the adapter's own wait, which nothing outside it
can end through an interface the adapter offers: one frame per deadline holds a stream
process indefinitely, whatever body then comes is refused, a WINDOW_UPDATE costs thirteen
bytes with nothing accumulated, and a HEADERS without END_STREAM writes a warning line per
frame to the host's log carrying the client's header bytes. The stream is the adapter's to
HTTP/2 a stream kept open by WINDOW_UPDATE frames alone is held past the deadline by the
adapter's own wait, which nothing outside it can end through an interface the adapter
offers: one frame per deadline holds a stream process indefinitely, whatever body then
comes is refused, and each frame costs thirteen bytes with nothing accumulated. (A HEADERS
without END_STREAM held a stream the same way until `Bandit` 1.13, which resets the stream
as a malformed request; this transport hands that error back to the adapter rather than
answering it.) The stream is the adapter's to
end, but the **connection** is this package's: `connection_timeout:` (default twice
`read_timeout`) closes a connection whose body read has been held that long, with nothing
else on it still within its own deadline, using a `GOAWAY` the client can read, so the
Expand Down
31 changes: 21 additions & 10 deletions docs/provenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ So the release tarball is built by one script on every seat: CI, the publisher,
reproducing it:

```sh
tools/release_tarball.sh v0.10.1 beam_mcp-0.10.1.tar
RELEASE_HEX=2.5.1 tools/release_tarball.sh v0.10.1 beam_mcp-0.10.1.tar # 0.10.1 was built with Hex 2.5.1
```

It `git archive`s the ref with `tar.umask=022` (every file `644` whatever the machine's umask,
Expand Down Expand Up @@ -102,9 +102,10 @@ applications), generated from the tag's tree by `tools/sbom.sh` with the EEF's `
(pinned by version and by the release asset's SHA-256; nothing is added to `mix.exs` or
`mix.lock`) and attested to the same tarball digest as the provenance. `plug` and `bandit`
appear although they are optional dependencies of this package: the tool has no notion of an
optional dependency. One workaround is named in the script (G-087 in this project's records:
the tool's 0.11.0 cannot read the `tools: :optional` entry in `mix.exs`, so its scratch copy
reads `:tools`; the component list is the same).
optional dependency. Releases up to 0.10.1 were generated with `mix_sbom` 0.11.0, which could
not read the `tools: :optional` entry in `mix.exs`, so the script rewrote that one line to
`:tools` in its scratch copy (the component list is the same). From 0.12.0 the tool reads the
form and the script reads the tree exactly as tagged.

```sh
gh attestation verify "beam_mcp-${v}.tar" --repo ScriptKittyOS/beam_mcp \
Expand All @@ -128,12 +129,22 @@ git clone https://github.com/ScriptKittyOS/beam_mcp && cd beam_mcp
tools/release_tarball.sh "v${v}" "rebuilt-${v}.tar" # prints the sha256 = the package checksum on hex.pm
```

The script needs Elixir, Erlang, Hex 2.x, bash, git and `sha256sum` or `shasum`. CI builds
with the pair `.tool-versions` names, copied into the workflow (Elixir 1.18 on OTP 28); the
tarball carries no compiled code, and Hex is what packages it: 2.4.0 and 2.5.1 gave
byte-identical tarballs of one commit (measured), and a packaging change in a later Hex would
show as a checksum the tag's run fails to match, not as a silent difference. The canonical
bytes assume ASCII file names: a non-ASCII name is encoded by the machine's locale, and a test
The script needs Elixir, Erlang, the Hex that `tools/hex-version` names, bash, git and
`sha256sum` or `shasum`, and it refuses any other Hex with the command that installs the right
one. CI builds with the pair `.tool-versions` names, copied into the workflow (Elixir 1.18 on
OTP 28), and installs the same Hex from the same file. The tarball carries no compiled code, and
Hex is what packages it, which is why Hex is pinned: 2.4.0 and 2.5.1 gave byte-identical
tarballs of one commit (measured), but 2.5.2 (hex_core 0.20) sorts the tarball's entries and its
metadata `files` list, so the same commit has a different checksum under 2.5.1 and under 2.5.2
or later (measured 2026-10-19). **Releases up to and including 0.10.1 were built with Hex 2.5.1**
and reproduce byte for byte only under it:

```sh
mix local.hex 2.5.1 --force # from inside the clone: a version manager keeps one Hex per Elixir
RELEASE_HEX=2.5.1 tools/release_tarball.sh v0.10.1 rebuilt-0.10.1.tar
```

The canonical bytes assume ASCII file names: a non-ASCII name is encoded by the machine's locale, and a test
holds every packaged name to ASCII.

## Releases before this page
Expand Down
Loading
Loading