From 44d23d22a4fb93a0d03130c18027aad47bf8380b Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Mon, 19 Oct 2026 17:02:30 -0400 Subject: [PATCH 1/2] Every leg at its line's patched OTP floor; FIPS on 28.5.0.8 on both architectures; the box leg CVE-2026-55737 (heap corruption in binary_to_term/1 decoding LARGE_TUPLE_EXT) is fixed in OTP 27.3.4.15, 28.5.0.4 and 29.0.4. Gate run 37704440253 on 336ce2b resolved 27.3.4.18, 28.5.0.7 and 29.1.1, all above those floors, but nothing asserted it, so a later resolution below a floor would have passed. The FIPS leg built 28.1.1, which is below its floor. - tools/otp-floors records each line's lowest patched release. tools/otp_floor.sh reads the running release's OTP_VERSION file (otp_release gives only the major, so 28.1.1 and 28.5.0.8 both read "28"). It refuses a runtime below its line's floor and a line older than every recorded one. The gate runs it as its second step, which makes seventeen steps. The FIPS job runs no gate, so it runs the script after its build. - The FIPS leg builds OTP 28.5.0.8 from source (sha256 from the release's SHA256.txt), with the same OpenSSL 3.5.8 and 3.1.2 provider as before. It runs on ubuntu-24.04 and on ubuntu-24.04-arm, and each architecture has its own cache. - A fourth gate leg, "box", runs OTP 28.5.0.8 with Elixir 1.20.4 (version-type: strict) on ubuntu-24.04-arm: the versions and the architecture a fielded edge box runs. Minutes for a public repository are free on both architectures. The Dialyzer cache key now includes the architecture. - .tool-versions: erlang 28.5.0.8. The summary job's check after a body edit (tools/ci_legs_verdict.sh) found legs by the pattern "OTP [0-9]+", which does not match the box leg's "OTP 28.5.0.8". A red box leg was therefore left out and the check passed. The pattern now accepts a dotted version, and LEGS_MIN is 4. The probe's new case S9, where only the box leg is red, failed before the fix: S9 the box leg alone is red exit 0 (want 1); last: pass: 3 leg(s) completed, all success and passes after it (exit 1, "a leg's latest verdict ... is not success"). Measured locally: 28.1.1: below the patched floor 28.5.0.4 for line 28 (CVE-2026-55737); refused 28.5.0.8: at or above the patched floor 28.5.0.4 for line 28 29.1: at or above the patched floor 29.0.4 for line 29 The gate on OTP 28.5.0.8 with Elixir 1.18.4 printed "Gate OK" with every step passing. On OTP 28.5.0.8 with Elixir 1.20.4 (x86_64), the run was stopped after ten steps, all of which had passed. Neither the box leg nor either FIPS job has run on aarch64 yet; this pull request's first CI run is that measurement. README, CONTRIBUTING, docs/governance.md and docs/assurance-case.md now say seventeen steps and four legs. docs/fips.md names 28.5.0.8 and both architectures, and labels its 2026-09-23 figures as measured on 28.1.1, x86_64. Signed-off-by: Ayla Croft --- .github/workflows/fips.yml | 30 ++++++++++++++++++++------- .github/workflows/gate.yml | 21 +++++++++++++------ .tool-versions | 2 +- CONTRIBUTING.md | 2 +- README.md | 2 +- docs/assurance-case.md | 2 +- docs/fips.md | 7 ++++--- docs/governance.md | 10 ++++----- tools/ci_legs_verdict.sh | 8 +++---- tools/gate.sh | 6 ++++++ tools/otp-floors | 8 +++++++ tools/otp-floors.license | 2 ++ tools/otp_floor.sh | 38 ++++++++++++++++++++++++++++++++++ tools/probe_ci_legs_verdict.sh | 30 ++++++++++++++++----------- 14 files changed, 126 insertions(+), 42 deletions(-) create mode 100644 tools/otp-floors create mode 100644 tools/otp-floors.license create mode 100755 tools/otp_floor.sh diff --git a/.github/workflows/fips.yml b/.github/workflows/fips.yml index c4ac12dc..767e62fc 100644 --- a/.github/workflows/fips.yml +++ b/.github/workflows/fips.yml @@ -3,7 +3,8 @@ # # The suite on Erlang/OTP in FIPS mode, against the validated OpenSSL FIPS provider. # -# What runs: OTP 28.1.1 (the pinned line) built with --enable-fips, over OpenSSL 3.5.8's +# What runs: OTP 28.5.0.8 (a patched release of the pinned line, at or above the CVE-2026-55737 +# floor in tools/otp-floors) built with --enable-fips, over OpenSSL 3.5.8's # libcrypto with the FIPS provider built from OpenSSL 3.1.2, the source CMVP certificate #4985 # (FIPS 140-3) validates, following its security policy (section 11.1: `enable-fips`, # `make install_fips`, then `openssl fipsinstall` on the machine that runs it). Pairing a newer @@ -22,6 +23,11 @@ # Not a validation claim: ubuntu-24.04 is not an operational environment #4985 names, and this # job proves the package's behaviour under FIPS mode, nothing about the module. See docs/fips.md. # +# Two architectures: x86_64 and aarch64 (GitHub's arm64 runner, free for public repositories), +# because the fielded edge box is aarch64 and `:crypto` rides on the OTP and OpenSSL built for +# the target. Each builds its own toolchain from the same checked sources; the cache key carries +# the architecture. +# # When: every push to main and every pull request that touches the code, weekly, and on demand. # The toolchain is built once and cached (keyed on the versions and this file); a warm run is # the suite plus a few minutes. Public repository: GitHub-hosted minutes are not billed. @@ -46,8 +52,12 @@ permissions: jobs: fips: - name: FIPS - OTP 28.1.1, OpenSSL FIPS provider 3.1.2 (CMVP 4985) - runs-on: ubuntu-24.04 + name: FIPS - OTP 28.5.0.8, OpenSSL FIPS provider 3.1.2 (CMVP 4985), ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, ubuntu-24.04-arm] timeout-minutes: 45 env: # Fixed absolute paths: the rpath, OpenSSL's MODULESDIR and OTP's ROOTDIR are baked in. @@ -61,9 +71,9 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: /home/runner/fips - key: fips-${{ runner.os }}-${{ runner.arch }}-ubuntu24-otp28.1.1-ossl3.5.8-fipsprov3.1.2-ex1.18.4-${{ hashFiles('.github/workflows/fips.yml') }} + key: fips-${{ runner.os }}-${{ runner.arch }}-ubuntu24-otp28.5.0.8-ossl3.5.8-fipsprov3.1.2-ex1.18.4-${{ hashFiles('.github/workflows/fips.yml') }} - - name: build OpenSSL 3.5.8 (libcrypto), the 3.1.2 FIPS provider, and OTP 28.1.1 --enable-fips + - name: build OpenSSL 3.5.8 (libcrypto), the 3.1.2 FIPS provider, and OTP 28.5.0.8 --enable-fips if: steps.cache.outputs.cache-hit != 'true' run: | set -euo pipefail @@ -72,12 +82,12 @@ jobs: dl() { curl -fsSLO "$1"; echo "$2 $(basename "$1")" | sha256sum -c -; } dl https://github.com/openssl/openssl/releases/download/openssl-3.5.8/openssl-3.5.8.tar.gz a8f84a39918ec6415ce765d9b429d313ba97b8143169c172e734b9514464f5b2 dl https://github.com/openssl/openssl/releases/download/openssl-3.1.2/openssl-3.1.2.tar.gz a0ce69b8b97ea6a35b96875235aa453b966ba3cba8af2de23657d8b6767d6539 - dl https://github.com/erlang/otp/releases/download/OTP-28.1.1/otp_src_28.1.1.tar.gz 03e1b26e846b2cc1b49271656da6d5ecf4d5f4c2e34224d869a23c61a3d5660a + dl https://github.com/erlang/otp/releases/download/OTP-28.5.0.8/otp_src_28.5.0.8.tar.gz 8a696e15bcbe5d57197b1f4c03c92d415f9fd9af70f9222a05911c8a5463cba6 dl https://builds.hex.pm/builds/elixir/v1.18.4-otp-28.zip cbd98899abdd4b1243d8ea6dae7258f9bc71cc2dcb55b8f9c241b07f04b3de67 - for t in openssl-3.5.8 openssl-3.1.2 otp_src_28.1.1; do tar xzf "$t.tar.gz"; done + for t in openssl-3.5.8 openssl-3.1.2 otp_src_28.5.0.8; do tar xzf "$t.tar.gz"; done (cd openssl-3.5.8 && ./Configure --prefix="$P" --openssldir="$P/ssl" --libdir=lib shared no-docs && make -j4 && make install_sw) (cd openssl-3.1.2 && ./Configure enable-fips --prefix="$P" --openssldir="$P/ssl" --libdir=lib shared && make -j4 && make install_fips) - (cd otp_src_28.1.1 && ./configure --prefix="$OTP" --enable-fips --with-ssl="$P" --with-ssl-rpath="$P/lib" --without-javac --without-odbc --without-wx && make -j4 && make install) + (cd otp_src_28.5.0.8 && ./configure --prefix="$OTP" --enable-fips --with-ssl="$P" --with-ssl-rpath="$P/lib" --without-javac --without-odbc --without-wx && make -j4 && make install) mkdir -p "$EX" && unzip -q v1.18.4-otp-28.zip -d "$EX" - name: fipsinstall on this machine, and the configuration that activates the provider @@ -92,6 +102,10 @@ jobs: echo "$OTP/bin" >> "$GITHUB_PATH" echo "$EX/bin" >> "$GITHUB_PATH" + # This job runs no gate, so the floor the gate holds every other leg to is asserted here: + # the OTP it just built is at or above its line's patched release (tools/otp-floors). + - name: the OTP built here is at or above its patched floor + run: bash tools/otp_floor.sh - name: FIPS mode is on, served by the 3.1.2 provider, and fails closed without it run: | set -euo pipefail diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index ae96fc83..2fbddf32 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -23,11 +23,18 @@ on: # image is observed, not assumed: the first run's log is the evidence, and the slice record # quotes it. `fail-fast: false` so one leg's red does not hide another's measurement. # +# THE BOX LEG: the pair a fielded edge box runs (OTP 28 at a patched release, Elixir 1.20), on +# the architecture it runs on (aarch64, GitHub's arm64 runner, free for public repositories). +# Its versions are exact (`version-type: strict`), not "the newest 28": the box pins its +# runtime, so the leg does too. Every leg's gate runs the OTP floor step (tools/otp_floor.sh), +# so a resolution below a line's patched floor is a red leg, not a green one on a vulnerable +# runtime. +# # THE REQUIRED CHECK IS THE SUMMARY JOB. The repository's ruleset on main requires the status # context "Gate - quality gate" by name; a matrix leg's context carries its values, so no leg # could satisfy it and a pull request would wait on a check that never reports. `summary` # carries that exact name, runs after every leg (`if: always()`), and fails unless the legs -# all succeeded -- so the ruleset stays as it is and the required check means "all three". +# all succeeded -- so the ruleset stays as it is and the required check means "every leg". # The token does nothing here but read: the legs read the tree, the summary reads check runs. # (What the Scorecard's Token-Permissions check asks for, and what the jobs need anyway.) permissions: @@ -36,15 +43,16 @@ permissions: jobs: gate: name: Gate - leg (OTP ${{ matrix.otp }}, Elixir ${{ matrix.elixir }}, ${{ matrix.leg }}) - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} if: github.event_name != 'pull_request' || github.event.action != 'edited' strategy: fail-fast: false matrix: include: - - { leg: floor, otp: "27", elixir: "1.17" } - - { leg: pinned, otp: "28", elixir: "1.18" } - - { leg: head, otp: "29", elixir: "1.20" } + - { leg: floor, otp: "27", elixir: "1.17", os: ubuntu-latest, version-type: loose } + - { leg: pinned, otp: "28", elixir: "1.18", os: ubuntu-latest, version-type: loose } + - { leg: head, otp: "29", elixir: "1.20", os: ubuntu-latest, version-type: loose } + - { leg: box, otp: "28.5.0.8", elixir: "1.20.4", os: ubuntu-24.04-arm, version-type: strict } steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -54,6 +62,7 @@ jobs: with: otp-version: ${{ matrix.otp }} elixir-version: ${{ matrix.elixir }} + version-type: ${{ matrix.version-type }} - name: the pair setup-beam installed (observed, quoted in the slice record) run: | erl -noshell -eval 'io:format("OTP ~s~n", [erlang:system_info(otp_release)]), halt().' @@ -67,7 +76,7 @@ jobs: - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: _build/dialyzer - key: dialyzer-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('mix.lock') }} + key: dialyzer-${{ runner.arch }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('mix.lock') }} # The leg says what it claims to be, so the gate can hold it to it: on the pinned leg a # format step that cannot measure (the running Elixir is not `.tool-versions`' line) is a # FAIL, not NOT MEASURED -- otherwise a pin moved in `.tool-versions` and not here would diff --git a/.tool-versions b/.tool-versions index a13962d2..cfaf56a4 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -erlang 28.1.1 +erlang 28.5.0.8 elixir 1.18.4-otp-28 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index baa1c4e1..9a2c17db 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,7 +66,7 @@ back, whoever wrote it. ## Code review **How it is done.** Every change reaches `main` through a pull request, the maintainer's -included, and is merged by rebase only after CI is green on all three OTP/Elixir pairs. The +included, and is merged by rebase only after CI is green on all four OTP/Elixir legs. The reviewer reads the diff, the commit messages and the CI results, and runs the change locally when it touches the wire or the host contract. `CONVENTIONS.md`'s tier rule sets the depth: a contract change (behaviour a consumer can hit) gets two independent review passes and mutation diff --git a/README.md b/README.md index ef77bf79..361d824c 100644 --- a/README.md +++ b/README.md @@ -556,7 +556,7 @@ has no stdio server mode). `conformance/README.md` has the rest. **Reproduce it:** `tools/conformance.sh`, one command, for anyone with Node ≥ 22 and `python3`. **The trade, stated:** this package has two dependencies; producing this number costs a second toolchain, so the step runs in a CI job of its own (`conformance.yml`, Node 22 -pinned) and never in the local gate, which stays the sixteen steps a contributor with Elixir +pinned) and never in the local gate, which stays the seventeen steps a contributor with Elixir and Erlang runs green with nothing else installed (Dialyzer among them, on the OTP binary; its PLT is built once per machine, about a minute, and kept under `_build`). The gate needs hex.pm (two steps resolve dependencies); of those, the dependency audit is the one that refuses an answer hex gives diff --git a/docs/assurance-case.md b/docs/assurance-case.md index 953e4123..df1e2f10 100644 --- a/docs/assurance-case.md +++ b/docs/assurance-case.md @@ -104,7 +104,7 @@ partial scheme. ## How the case is kept true -- The gate runs on every push on three OTP/Elixir pairs; the censuses above fail when the code +- The gate runs on every push on four OTP/Elixir legs (three pairs on x86_64, and the box pair, OTP 28.5.0.8 with Elixir 1.20.4, on aarch64), each held to its line's patched OTP floor; the censuses above fail when the code they describe changes. - The threat model's citations and the will-not-implement page's entries are each held to the test suite by a census of their own. diff --git a/docs/fips.md b/docs/fips.md index 62d113d2..766e481b 100644 --- a/docs/fips.md +++ b/docs/fips.md @@ -97,15 +97,16 @@ provider) and of the process that certified it, and this package holds no such m ## Measured: this package in FIPS mode `.github/workflows/fips.yml` runs the suite on every push to `main`, on each pull request that -touches the code, and weekly, on this toolchain: Erlang/OTP 28.1.1 (the pinned line) built -with `--enable-fips`, over OpenSSL 3.5.8's libcrypto with the FIPS provider built from +touches the code, and weekly, on this toolchain, on x86_64 and on aarch64: Erlang/OTP +28.5.0.8 (a patched release of the pinned line; until 2026-10-19 it was 28.1.1, below the +CVE-2026-55737 fix) built with `--enable-fips`, over OpenSSL 3.5.8's libcrypto with the FIPS provider built from OpenSSL 3.1.2, the source that CMVP certificate #4985 (FIPS 140-3) validates, following its security policy (`enable-fips`, `make install_fips`, `openssl fipsinstall` on the machine that runs it). Before any test runs, the same VM asserts `:crypto.info_fips()` is `:enabled` and the provider reports build `3.1.2`; a negative control shows that without the provider configuration `fips_mode true` gives no working `crypto`. -Measured 2026-09-23 on that toolchain: `:crypto.info_fips()` answers `:enabled`, +Measured 2026-09-23 on the 28.1.1 toolchain, x86_64 (the 28.5.0.8 runs on both architectures are quoted in the next release's record, from their CI logs): `:crypto.info_fips()` answers `:enabled`, `fips_provider_available: true`, `fips_provider_buildinfo: "3.1.2"`, the linked library OpenSSL 3.5.8; `:crypto.hash(:md5, _)` raises `notsup` ("Bad digest type in FIPS"); SHA-256, SHA-384 and SHA-512 answer, SHA-256 of `abc` being the FIPS 180-4 test vector; and diff --git a/docs/governance.md b/docs/governance.md index 1f5b7d21..19b3d094 100644 --- a/docs/governance.md +++ b/docs/governance.md @@ -31,12 +31,12 @@ Every change, the maintainer's included, goes the same way: force-push, no deletion, linear history, and two required status checks: the DCO sign-off and the quality gate. A pull request is the only way onto `main`, and it is rebased, never merged, so the history is a line. -2. **The gate.** `tools/gate.sh` runs the same sixteen steps locally and in CI (format, - compile with warnings as errors, Dialyzer, the instruments' parse, the suite, Credo, the +2. **The gate.** `tools/gate.sh` runs the same seventeen steps locally and in CI (format, + the runtime's patched OTP floor, compile with warnings as errors, Dialyzer, the instruments' parse, the suite, Credo, the properties, the optional-dependency probe, the dependency audit, the benchmarks, the docs, REUSE, the licence files, the publication census, the public-API baseline against - `origin/main` and the commit-message terms) on three - OTP/Elixir pairs (the floor, the pinned line, the newest). There is no baseline to hold: a + `origin/main` and the commit-message terms) on four + OTP/Elixir legs (the floor, the pinned line, the newest, and the box pair on aarch64). There is no baseline to hold: a non-zero count is a failure. 3. **Review by tier, then the merge word.** `CONVENTIONS.md` states the tier rule: a contract change (behaviour a consumer can hit) gets two independent review lanes and mutation @@ -73,7 +73,7 @@ promised, and moves when the Scorecard next runs. | License | `LICENSE`, `NOTICE`, `LICENSES/`, REUSE headers on every file, held by the gate | | 10 | | Dependency-Update-Tool | Dependabot, weekly, Mix and GitHub Actions | | 10 | | Vulnerabilities | `mix hex.audit` in the gate, OSV-fed, on every push | | 10 | -| CI-Tests | the gate on three OTP/Elixir pairs | | 10, "7 out of 7 merged PRs checked" | +| CI-Tests | the gate on four OTP/Elixir legs | | 10, "7 out of 7 merged PRs checked" | | Maintained | commits and releases as the CHANGELOG shows | the check scores **0 for any repository younger than 90 days**, whatever its activity; this one was created 2026-09-06, so the figure is the rule's until 2026-12-05 and says nothing about the tree | 0, "created within the last 90 days" | | Signed-Releases | releases are Hex releases: from `0.6.0` on, the tarball is built by CI on the tag and attested (`docs/provenance.md`; `0.5.0` and earlier carry none); there are no GitHub Releases with assets for this check to read | the attestation binds to the checksum hex.pm shows, which is where consumers fetch from; a GitHub Release would be a copy | −1, "no releases found": the check reads GitHub Releases only | | Packaging | the package is published to hex.pm by the maintainer from the canonical tarball, on a signed tag; no GitHub Actions publishing workflow | the publish step holds a Hex API key, which stays on the maintainer's seat rather than in a workflow secret: a decision, recorded here; the provenance workflow attests the bytes but does not publish them | −1, "packaging workflow not detected": the check reads a publishing workflow only | diff --git a/tools/ci_legs_verdict.sh b/tools/ci_legs_verdict.sh index cb3623a6..7729e45a 100755 --- a/tools/ci_legs_verdict.sh +++ b/tools/ci_legs_verdict.sh @@ -5,7 +5,7 @@ # The edited-summary read, out of the workflow and into a script a probe can run with a fake # `gh`. On a pull-request body edit the legs do not run (the tree did not move) but the # summary job carries the REQUIRED context, so it reads the legs' latest check runs for the -# head SHA and requires all three to be success. G-079 measured the hole: a body edit while the +# head SHA and requires every one to be success. G-079 measured the hole: a body edit while the # same SHA's legs were still running read their conclusions as null and failed "by design", # and the ruleset then blocked the pull request on that red although the legs finished green a # minute later -- a failed run of the required name blocks even beside a later green one. So @@ -13,17 +13,17 @@ # then judges. A tree whose legs never finish is a FAIL at the bound, named as such. # # GH_TOKEN=... tools/ci_legs_verdict.sh -# env: LEGS_MIN (default 3), POLL_SECONDS (default 30), POLL_LIMIT (default 40 -- 20 minutes). +# env: LEGS_MIN (default 4: floor, pinned, head, box), POLL_SECONDS (default 30), POLL_LIMIT (default 40 -- 20 minutes). # exit 0: every leg completed and success; 1: a leg not success, too few legs, or the bound hit. set -uo pipefail [ $# -eq 2 ] || { echo "usage: tools/ci_legs_verdict.sh " >&2; exit 2; } repo=$1 sha=$2 -min=${LEGS_MIN:-3} every=${POLL_SECONDS:-30} limit=${POLL_LIMIT:-40} +min=${LEGS_MIN:-4} every=${POLL_SECONDS:-30} limit=${POLL_LIMIT:-40} # The expanded leg names only: the skipped matrix job of the edited run registers one # unexpanded name ("Gate - leg (OTP ${{ matrix.otp }} ...", skipped), which is not a verdict. read_legs() { gh api "repos/${repo}/commits/${sha}/check-runs?filter=latest&per_page=100" \ - --jq '.check_runs[] | select(.name | test("^Gate - leg \\(OTP [0-9]+, Elixir [0-9.]+, [a-z]+\\)$")) | "\(.name): \(.status)/\(.conclusion)"' + --jq '.check_runs[] | select(.name | test("^Gate - leg \\(OTP [0-9][0-9.]*, Elixir [0-9.]+, [a-z]+\\)$")) | "\(.name): \(.status)/\(.conclusion)"' } i=0 while :; do diff --git a/tools/gate.sh b/tools/gate.sh index 7cea6cb6..27de3bbd 100755 --- a/tools/gate.sh +++ b/tools/gate.sh @@ -71,6 +71,12 @@ else printf '%s\n' "$fmt_out" | sed 's/^/ /'; fail=1 fi fi +# The runtime is at or above its line's patched floor (tools/otp-floors: CVE-2026-55737). The +# leg's own OTP, whatever setup-beam resolved, so a resolution below the floor is a FAIL here +# rather than a green leg on a vulnerable runtime. +of_out=$(bash tools/otp_floor.sh 2>&1); of_rc=$? +if [ "$of_rc" -eq 0 ]; then note "otp floor" "pass ($of_out)" +else note "otp floor" "FAIL ($of_out)"; fail=1; fi step "compile" mix compile --warnings-as-errors --force # Dialyzer over the compiled beams: the OTP binary (dialyxir declined, CX-055), a PLT under diff --git a/tools/otp-floors b/tools/otp-floors new file mode 100644 index 00000000..87b4f4b5 --- /dev/null +++ b/tools/otp-floors @@ -0,0 +1,8 @@ +# The lowest patched release of each Erlang/OTP line this project runs on: CVE-2026-55737 +# (heap corruption in binary_to_term/1 decoding LARGE_TUPLE_EXT) is fixed in 27.3.4.15, +# 28.5.0.4 and 29.0.4 (https://www.erlang.org/patches/otp-28.5.0.4). tools/otp_floor.sh +# refuses a runtime below its line's floor. A line here moves only to a later patch. +# major floor +27 27.3.4.15 +28 28.5.0.4 +29 29.0.4 diff --git a/tools/otp-floors.license b/tools/otp-floors.license new file mode 100644 index 00000000..f1cc2499 --- /dev/null +++ b/tools/otp-floors.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +SPDX-License-Identifier: Apache-2.0 diff --git a/tools/otp_floor.sh b/tools/otp_floor.sh new file mode 100755 index 00000000..1e1bdc09 --- /dev/null +++ b/tools/otp_floor.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# The running Erlang/OTP is at or above its line's patched floor (tools/otp-floors), or this +# refuses by name. The full version is read from the release's own OTP_VERSION file, not from +# `otp_release` (which is the major alone, so 28.1.1 and 28.5.0.8 both answer "28"). A line +# newer than every recorded one passes and says so; a line older than every recorded one, or a +# recorded line below its floor, is refused. Used by tools/gate.sh and by the FIPS workflow, +# which builds its OTP from source and runs no gate. +set -euo pipefail +root=$(git rev-parse --show-toplevel) +floors="$root/tools/otp-floors" +have=$(erl -noshell -eval ' + R = erlang:system_info(otp_release), + F = filename:join([code:root_dir(), "releases", R, "OTP_VERSION"]), + {ok, B} = file:read_file(F), + io:format("~s", [string:trim(B)]), + halt().') +have=${have%%[!0-9.]*} +major=${have%%.*} +floor=$(awk -v m="$major" '$1 == m { print $2 }' "$floors") +newest=$(awk '$1 ~ /^[0-9]+$/ { print $1 }' "$floors" | sort -n | tail -1) +if [ -z "$floor" ]; then + if [ "$major" -gt "$newest" ]; then + echo "OTP $have: a line newer than every recorded floor (newest recorded: $newest); passes" + exit 0 + fi + echo "OTP $have: line $major has no patched release recorded in tools/otp-floors; refused" + exit 1 +fi +lowest=$(printf '%s\n%s\n' "$floor" "$have" | sort -V | head -1) +if [ "$lowest" = "$floor" ]; then + echo "OTP $have: at or above the patched floor $floor for line $major" +else + echo "OTP $have: below the patched floor $floor for line $major (CVE-2026-55737); refused" + exit 1 +fi diff --git a/tools/probe_ci_legs_verdict.sh b/tools/probe_ci_legs_verdict.sh index 37c03f12..eccacb42 100755 --- a/tools/probe_ci_legs_verdict.sh +++ b/tools/probe_ci_legs_verdict.sh @@ -38,31 +38,31 @@ run() { # run } # S1: G-079 -- two polls in progress (conclusion null), then all success. d=$work/s1; mkdir -p "$d" -page "$(leg 27 1.17 floor in_progress null)" "$(leg 28 1.18 pinned in_progress null)" "$(leg 29 1.20 head completed '"success"')" "$skipped" > "$d/page.0" -page "$(leg 27 1.17 floor completed '"success"')" "$(leg 28 1.18 pinned in_progress null)" "$(leg 29 1.20 head completed '"success"')" "$skipped" > "$d/page.1" -page "$(leg 27 1.17 floor completed '"success"')" "$(leg 28 1.18 pinned completed '"success"')" "$(leg 29 1.20 head completed '"success"')" "$skipped" > "$d/page.2" -run "S1 in_progress twice, then three successes" 0 "$d" +page "$(leg 27 1.17 floor in_progress null)" "$(leg 28 1.18 pinned in_progress null)" "$(leg 29 1.20 head completed '"success"')" "$(leg 28.5.0.8 1.20.4 box completed '"success"')" "$skipped" > "$d/page.0" +page "$(leg 27 1.17 floor completed '"success"')" "$(leg 28 1.18 pinned in_progress null)" "$(leg 29 1.20 head completed '"success"')" "$(leg 28.5.0.8 1.20.4 box completed '"success"')" "$skipped" > "$d/page.1" +page "$(leg 27 1.17 floor completed '"success"')" "$(leg 28 1.18 pinned completed '"success"')" "$(leg 29 1.20 head completed '"success"')" "$(leg 28.5.0.8 1.20.4 box completed '"success"')" "$skipped" > "$d/page.2" +run "S1 in_progress twice, then four successes" 0 "$d" # S2: a leg red once complete. d=$work/s2; mkdir -p "$d" -page "$(leg 27 1.17 floor in_progress null)" "$(leg 28 1.18 pinned completed '"success"')" "$(leg 29 1.20 head completed '"success"')" > "$d/page.0" -page "$(leg 27 1.17 floor completed '"failure"')" "$(leg 28 1.18 pinned completed '"success"')" "$(leg 29 1.20 head completed '"success"')" > "$d/page.1" +page "$(leg 27 1.17 floor in_progress null)" "$(leg 28 1.18 pinned completed '"success"')" "$(leg 29 1.20 head completed '"success"')" "$(leg 28.5.0.8 1.20.4 box completed '"success"')" > "$d/page.0" +page "$(leg 27 1.17 floor completed '"failure"')" "$(leg 28 1.18 pinned completed '"success"')" "$(leg 29 1.20 head completed '"success"')" "$(leg 28.5.0.8 1.20.4 box completed '"success"')" > "$d/page.1" run "S2 a leg completes as failure" 1 "$d" # S3: a leg never completes -- the bound. d=$work/s3; mkdir -p "$d" -page "$(leg 27 1.17 floor in_progress null)" "$(leg 28 1.18 pinned completed '"success"')" "$(leg 29 1.20 head completed '"success"')" > "$d/page.0" +page "$(leg 27 1.17 floor in_progress null)" "$(leg 28 1.18 pinned completed '"success"')" "$(leg 29 1.20 head completed '"success"')" "$(leg 28.5.0.8 1.20.4 box completed '"success"')" > "$d/page.0" run "S3 a leg never completes (bound of 3 polls)" 1 "$d" -# S4: only two legs exist, both green -- the tree is unmeasured on one pair. +# S4: only two legs exist, both green -- the tree is unmeasured on two pairs. d=$work/s4; mkdir -p "$d" page "$(leg 28 1.18 pinned completed '"success"')" "$(leg 29 1.20 head completed '"success"')" "$skipped" > "$d/page.0" run "S4 two legs only, the skipped unexpanded name beside" 1 "$d" -# S5: all three complete and green on the first read -- no wait. +# S5: all four complete and green on the first read -- no wait. d=$work/s5; mkdir -p "$d" cp "$work/s1/page.2" "$d/page.0" -run "S5 three successes on the first read" 0 "$d" -# S6: the API fails once, then three successes -- a read spent, not a verdict. +run "S5 four successes on the first read" 0 "$d" +# S6: the API fails once, then four successes -- a read spent, not a verdict. d=$work/s6; mkdir -p "$d" echo '{"__fail__":true}' > "$d/page.0"; cp "$work/s1/page.2" "$d/page.1" -run "S6 the API fails once, then three successes" 0 "$d" +run "S6 the API fails once, then four successes" 0 "$d" # S7: the API fails every time -- the bound, named as a failed read. d=$work/s7; mkdir -p "$d" echo '{"__fail__":true}' > "$d/page.0" @@ -71,4 +71,10 @@ run "S7 the API fails every time (bound)" 1 "$d" d=$work/s8; mkdir -p "$d" echo '{"check_runs":[]}' > "$d/page.0" run "S8 no check runs at all" 1 "$d" +# S9: the box leg (an exact OTP version, "28.5.0.8") is the only red one. Its name must be read +# as a leg at all: a pattern for OTP majors alone ("OTP [0-9]+") skipped it, so a red box leg +# passed the edited-path verdict. +d=$work/s9; mkdir -p "$d" +page "$(leg 27 1.17 floor completed '"success"')" "$(leg 28 1.18 pinned completed '"success"')" "$(leg 29 1.20 head completed '"success"')" "$(leg 28.5.0.8 1.20.4 box completed '"failure"')" > "$d/page.0" +run "S9 the box leg alone is red" 1 "$d" if [ "$bad" -eq 0 ]; then echo "PROBE OK: the read waits for running legs, then judges; red, too few, and the bound are FAIL"; else echo "PROBE FAILED"; exit 1; fi From df0e408aaae9bbd59561756c899896a425413097 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Mon, 19 Oct 2026 17:42:49 -0400 Subject: [PATCH 2/2] Keep only the calling process's log lines where a test refutes "[error]" The box leg of gate run 39324309977 (push, 44d23d2) failed http_test.exs:1679 on an error line that readme_claims_test.exs logged at the same moment. ExUnit.CaptureLog hands every capture the events of every process, and Elixir's own docs warn that an async test may capture another test's messages. The four `refute log =~ "[error]"` checks in "an exception the adapter raises goes back to the adapter as itself" were open to that leak. The same commit's pull_request run passed with a different seed. own_log/1 captures with `metadata: [:pid]` and keeps only the entries logged by the calling process. Every Logger.error in http.ex on these paths runs in the process that called HTTP.call/2, which here is the test process, so the refutes still see everything the transport logs. Measured locally beside a temporary async module that logs an error every 2 ms: before (capture_log): 117 tests, 1 failure (the other module's line) after (own_log/1): 117 tests, 0 failures mutant (http.ex logs before handing the exception back, own_log/1): 117 tests, 3 failures Signed-off-by: Ayla Croft --- test/beam_mcp/transport/http_test.exs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/test/beam_mcp/transport/http_test.exs b/test/beam_mcp/transport/http_test.exs index a1ea42e1..af2d5a10 100644 --- a/test/beam_mcp/transport/http_test.exs +++ b/test/beam_mcp/transport/http_test.exs @@ -1645,12 +1645,27 @@ defmodule BeamMCP.Transport.HTTPTest do PlantedAdapter.wrap(conn, raise_in, exception) end - # Returns the exception and stacktrace `HTTP.call/2` raised, and what was logged meanwhile. + # What the calling process logged while `fun` ran. `capture_log/2` hands every capture the + # events of every process, so in an async run another module's errors land in it too (the + # box leg of gate run 39324309977: readme_claims_test.exs's refused-schema line failed a + # `refute log =~ "[error]"` here). This transport logs from the process that called it, which + # in these tests is the test's own, so keeping that process's entries drops nothing it logs. + defp own_log(fun) do + me = "pid=#{:erlang.pid_to_list(self())} " + + [metadata: [:pid]] + |> ExUnit.CaptureLog.capture_log(fun) + |> String.split("\n") + |> Enum.filter(&String.contains?(&1, me)) + |> Enum.join("\n") + end + + # Returns the exception and stacktrace `HTTP.call/2` raised, and what it logged meanwhile. defp raised_by_call(conn) do parent = self() log = - ExUnit.CaptureLog.capture_log(fn -> + own_log(fn -> try do HTTP.call(conn, opts()) send(parent, {:returned, :no_raise}) @@ -1745,7 +1760,7 @@ defmodule BeamMCP.Transport.HTTPTest do conn = planted_post(:read_req_body, timeout) log = - ExUnit.CaptureLog.capture_log(fn -> + own_log(fn -> conn = HTTP.call(conn, opts()) assert conn.status == 408 assert body!(conn)["error"]["code"] == -32_600