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/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 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