From bfae97f89a6999163a322d6c677ae8f25052d314 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 20 Oct 2026 09:08:06 -0400 Subject: [PATCH 1/2] feat(s141): complete slice 141 (the headless release's floor, OTP 28.5.0.8 on the OTP 28 line; the decoder asked directly; the container images on 28.5.0.8) Signed-off-by: Ayla Croft --- .github/workflows/gate.yml | 14 ++ CHANGELOG.md | 17 ++ README.md | 7 +- VERSIONS.md | 2 +- ci/container.tool-versions | 2 +- ci/fips/Containerfile | 6 +- ci/headless/justifications.yaml | 6 +- ci/ironbank/Dockerfile | 4 +- ci/ironbank/hardening_manifest.yaml | 6 +- config/runtime.exs | 2 +- docs/07-security-model.md | 25 +++ docs/adr/0014-otp-pins-split.md | 36 +++++ docs/fips-leg.md | 4 +- docs/regulated/control-mapping.md | 2 +- docs/regulated/headless-image.md | 20 ++- lib/trinity/application.ex | 4 +- lib/trinity/tls.ex | 6 +- lib/trinity/toolchain.ex | 223 ++++++++++++++++++++++----- lib/trinity/versions.ex | 6 +- priv/otp_floor.table | 7 +- test/ironbank_submission_test.exs | 8 +- test/test_helper.exs | 5 + test/trinity/toolchain_node_test.exs | 147 ++++++++++++++++++ test/trinity/toolchain_test.exs | 123 ++++++++++++++- 24 files changed, 604 insertions(+), 78 deletions(-) diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 1f4a4d0..ea2c8b2 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -116,6 +116,20 @@ jobs: (cd /tmp/otp-28.5.0.3 && ./Install -minimal "$PWD" > /dev/null) TRINITY_BELOW_FLOOR_OTP=/tmp/otp-28.5.0.3/bin mix test --only below_floor_otp test/trinity/toolchain_node_test.exs + # Slice 141: the headless release's floor is OTP 28.5.0.8. OTP 28.5.0.7 is the release just + # before it: a headless node on it must be refused, naming erts and ssl, while a desktop node + # on the same runtime boots. Prebuilt by Hex for this runner's OS, + # pinned by the sha256 in builds.txt (matched a download on 2026-10-20). The untagged case + # beside it runs in `mix gate` on this job's own runtime, where a headless node refuses. + - name: A headless boot on OTP 28.5.0.7, below the headless floor, refuses + run: | + curl -fsSL -o /tmp/otp-28.5.0.7.tar.gz https://builds.hex.pm/builds/otp/ubuntu-24.04/OTP-28.5.0.7.tar.gz + echo "90f1dfa231e1536069e445e0d212a8b8fb57f2e8601c55062f500b2a42699a3d /tmp/otp-28.5.0.7.tar.gz" | sha256sum -c - + mkdir -p /tmp/otp-28.5.0.7 + tar -xzf /tmp/otp-28.5.0.7.tar.gz -C /tmp/otp-28.5.0.7 --strip-components=1 + (cd /tmp/otp-28.5.0.7 && ./Install -minimal "$PWD" > /dev/null) + TRINITY_BELOW_HEADLESS_FLOOR_OTP=/tmp/otp-28.5.0.7/bin mix test --only below_headless_floor_otp test/trinity/toolchain_node_test.exs + # Slice 120 AC1. `reuse lint` is a Python tool and this is the only leg that installs one, # deliberately: `mix gate` has to stay runnable inside the FIPS container and on a developer's # machine without a Python environment, so the REUSE check is a step here rather than a row in diff --git a/CHANGELOG.md b/CHANGELOG.md index 506bde1..403520a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,23 @@ algorithm, Ed25519 admitted at `nss`, any `typ` admitted in the carrier, a refus apart from a bad key by the error alone, a key's registered algorithm ignored) each turned their own tests red. +### `slice/141`: the headless release asks for OTP 28.5.0.8 + +The headless release, the one deployed as a server, now refuses to boot below OTP 28.5.0.8 and on +any OTP line but 28. 28.5.0.8 fixes CVE-2026-94200 and CVE-2026-105236 in ssl, CVE-2026-42789 in +public_key and CVE-2026-48856 in inets. Those fixes are in the applications, and 28.5.0.8's erts +can be installed without them, so the check compares every loaded application with 28.5.0.8's row +of erlang/otp's `otp_versions.table`, checked in verbatim. The desktop keeps the slice 137 floor, because its runtime is the one Burrito can +fetch; ADR-0014 carries a proposed amendment that says so. Every node now also asks its decoder +directly whether it refuses the oversize tuple CVE-2026-55737 is about, instead of trusting the +version alone. The container images build OTP 28.5.0.8. + +Verified by the test suite: the floor at each boundary for both releases; a real headless boot on +the suite's own runtime, refused on 28.5.0.6 and booting on 28.5.0.8, with the expectation read from +the runtime's `OTP_VERSION` file; a real boot on OTP 28.5.0.7, refused as headless and booting +as desktop; a runtime with 28.5.0.8's erts and an older ssl refused, naming ssl; and the decoder probe catching the defect on OTP 28.1.1 and +28.5.0.3. + ## 2026-10-08 ### `slice/136`: the web pages ask who is there diff --git a/README.md b/README.md index c40ff14..4cfa6b9 100644 --- a/README.md +++ b/README.md @@ -210,9 +210,10 @@ demonstrate, each tied to the slice that proves it. ## Running from source Requires the pinned toolchain in `.tool-versions` (Erlang 28.5.0.6, Elixir 1.20.4, Zig 0.16.0), -installed with `asdf install`. The container images build Erlang 28.5.0.7 from source -(`ci/container.tool-versions`); the desktop's pin waits for Burrito's builds of it and, until then, -holds every outbound TLS connection to TLS 1.2 (`docs/adr/0014-otp-pins-split.md`). Rust 1.92.0 is pinned separately in `rust-toolchain.toml` and is +installed with `asdf install`. The container images build Erlang 28.5.0.8 from source +(`ci/container.tool-versions`), the floor the headless release refuses to boot below (Slice 141); +the desktop's pin waits for Burrito's builds and, until then, holds every outbound TLS connection +to TLS 1.2 (`docs/adr/0014-otp-pins-split.md`). A headless node run from source needs 28.5.0.8 too. Rust 1.92.0 is pinned separately in `rust-toolchain.toml` and is only needed for the desktop shell. ``` diff --git a/VERSIONS.md b/VERSIONS.md index 559902a..1ecb148 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -69,7 +69,7 @@ never pin a version hex marks as retired or vulnerable. | Name | Pin | Verified | Note | |---|---|---|---| | `Erlang/OTP` | **28.5.0.6** | ✅ `.tool-versions` | The desktop build and the gate. Pinned to the newest OTP whose ERTS Burrito 1.6.0 can fetch for all three targets (macOS universal, Linux x86_64, Windows), measured by probing Burrito's artifact sources, not read from a README (ADR-0005). Was 28.5.0.5 from Slice 000. 28.5.0.6 since 2026-10-08 (owner decision D2, ADR-0014): 200 on all three; 28.5.0.7, which fixes CVE-2026-89422, is 404 on Burrito's CDN for Linux and macOS, so this runtime carries that CVE and `Trinity.TLS` holds every outbound TLS client to TLS 1.2 while it runs. `.github/workflows/otp-canary.yml` probes the CDN daily and opens the pull request that moves this pin to the container's. ⚠️ Windows tracks OTP releases immediately while the other two lag a third-party CDN's build queue. | -| `Erlang/OTP (container images)` | **28.5.0.7** | ✅ `ci/container.tool-versions` | Added 2026-10-08 (owner decision D2, ADR-0014): the OTP the hardened headless image (`ci/ironbank/`) and the FIPS leg's image (`ci/fips/Containerfile`) build from source, so it does not wait for Burrito. 28.5.0.7 (released 2026-09-22) fixes CVE-2026-89422 (ssl, Critical) and CVE-2026-65634 (asn1, High). The source archive's SHA-256, `ddf17db6d3e9b7a7cfac0d72238ddc8ea040fedc5e3dfad82fc90675319d6c93`, was read from the release's SHA256.txt and its asset digest and matched a download. `mix trinity.ironbank.lint` holds the manifest to this file, and this pin to the desktop's major and to no older than it. | +| `Erlang/OTP (container images)` | **28.5.0.8** | ✅ `ci/container.tool-versions` | Added 2026-10-08 (owner decision D2, ADR-0014): the OTP the hardened headless image (`ci/ironbank/`) and the FIPS leg's image (`ci/fips/Containerfile`) build from source, so it does not wait for Burrito. Moved from 28.5.0.7 to 28.5.0.8 on 2026-10-20 (slice 141), the headless release's floor (`Trinity.Toolchain`): 28.5.0.8 (released 2026-10-14) fixes CVE-2026-94200 and CVE-2026-105236 (ssl), CVE-2026-42789 (public_key) and CVE-2026-48856 (inets). The source archive's SHA-256, `8a696e15bcbe5d57197b1f4c03c92d415f9fd9af70f9222a05911c8a5463cba6`, was read from the release's SHA256.txt and its asset digest and matched a download. `mix trinity.ironbank.lint` holds the manifest to this file, and this pin to the desktop's major and to no older than it. | | `Elixir` | **1.20.4-otp-28** | ✅ `.tool-versions` | Confirmed at Slice 000: `elixir --version` reports Elixir 1.20.4 on Erlang/OTP 28, erts-16.4.0.5. Built-in type checker is part of the gate. `boundary` 0.10.4 compiles and enforces on this pair, measured at Slice 000 (H7). | | `asdf` | v0.18.0 | 📐 `asdf --version` | `.tool-versions` committed in Slice 000. `mise` is absent on the build machine; measured at Slice 000 G1 with `which mise asdf`. asdf cannot pin itself, so this row is a command, not a file. ⚠️ Measured at Slice 001 line 3: asdf does **not** fail on a tool it has no plugin for, a `rust 1.92.0` line is omitted from `asdf current` and `asdf install` still exits 0. A pin file entry is only a pin where a plugin exists. | | `Rust` | **1.92.0** | ✅ `rust-toolchain.toml` | Measured at Slice 001 line 3: `rustc --version` reports 1.92.0 (ded5c06cf 2025-12-08), exit 0. Pinned in `rust-toolchain.toml`, **not** `.tool-versions`: `asdf` here has no rust plugin and silently ignores a rust line, whereas `rustup show active-toolchain` reports this file as an override. See NOTES.md deviation D1. Corrected 2026-09-06: this row previously read `Rust + Tauri CLI | stable | ✅ .tool-versions`, which named a file carrying neither. | diff --git a/ci/container.tool-versions b/ci/container.tool-versions index 406acc4..2929bf3 100644 --- a/ci/container.tool-versions +++ b/ci/container.tool-versions @@ -7,4 +7,4 @@ # comes from Burrito's ERTS builds and a container's does not (ADR-0014). # `mix trinity.ironbank.lint` holds the manifest's OTP resource to this line, the two pins to one # OTP major, and this pin to no older than the desktop's. -erlang 28.5.0.7 +erlang 28.5.0.8 diff --git a/ci/fips/Containerfile b/ci/fips/Containerfile index c59f6af..58cbe10 100644 --- a/ci/fips/Containerfile +++ b/ci/fips/Containerfile @@ -10,8 +10,8 @@ # ci/container.tool-versions, the container images' OTP, and Elixir from .tool-versions (ADR-0014: # the container's OTP can lead the desktop's). The two archives are checked against digests pinned # here; a new OTP or Elixir pin is a new digest in this file, read from the release page. -# OTP 28.5.0.7's digest was read from the release's SHA256.txt and the release API's asset digest, -# and matched a download, on 2026-10-08. +# OTP 28.5.0.8's digest was read from the release's SHA256.txt and the release API's asset digest, +# and matched a download, on 2026-10-20 (slice 141; 28.5.0.7 on 2026-10-08). # # Base pinned by digest, not tag: registry.access.redhat.com/ubi9/ubi:latest resolved on # 2026-09-20 (Red Hat Enterprise Linux release 9.8, openssl-libs 3.5.8-1.el9_8, FIPS provider @@ -20,7 +20,7 @@ FROM registry.access.redhat.com/ubi9/ubi@sha256:9295c5c688f487fa5cf27a734fa55ecd ARG OTP_VERSION ARG ELIXIR_VERSION -ARG OTP_SHA256=ddf17db6d3e9b7a7cfac0d72238ddc8ea040fedc5e3dfad82fc90675319d6c93 +ARG OTP_SHA256=8a696e15bcbe5d57197b1f4c03c92d415f9fd9af70f9222a05911c8a5463cba6 ARG ELIXIR_SHA256=ea7ff98bc1ed76c663a5d034c863c6fd37e65c9c855b6bbf6ccb2034d806673c # The toolchain OTP's configure needs, plus what the gate's steps call from the shell: git diff --git a/ci/headless/justifications.yaml b/ci/headless/justifications.yaml index 2dca36d..52d0452 100644 --- a/ci/headless/justifications.yaml +++ b/ci/headless/justifications.yaml @@ -14,11 +14,13 @@ # What a justification here must be: facts someone else can check, with the command or file that # shows them. "Not exploitable" with no evidence is not one. A finding that has a fix available is # fixed, not justified: the OTP findings fixed in 28.5.0.7 never had rows, and moving the container -# images to 28.5.0.7 fixed them (ADR-0014). +# images to 28.5.0.7 fixed them (ADR-0014). The images moved to 28.5.0.8 on 2026-10-20 (slice 141). # # The ERTS facts below were re-read in OTP 28.5.0.7's source on 2026-10-08, when the image moved to # it: `erts/emulator/pcre/pcre2.h` gives PCRE2_MAJOR 10, PCRE2_MINOR 47; the directory's 39 files -# include no pcre2_jit_*.c and no pcre2_dfa_match.c (`tar -tzf otp_src_28.5.0.7.tar.gz`). +# include no pcre2_jit_*.c and no pcre2_dfa_match.c (`tar -tzf otp_src_28.5.0.7.tar.gz`). On +# 2026-10-20 `erts/emulator/pcre/` in otp_src_28.5.0.8.tar.gz was compared with 28.5.0.7's +# (`diff -r` over both extracted directories): the same files, byte for byte, so the facts hold. --- justifications: # Retired 2026-10-19, from the scan of run 39257269071 (grype database built 2026-10-19T06:39Z): diff --git a/ci/ironbank/Dockerfile b/ci/ironbank/Dockerfile index d5b515f..0a5f2c2 100644 --- a/ci/ironbank/Dockerfile +++ b/ci/ironbank/Dockerfile @@ -38,13 +38,13 @@ RUN dnf -y install --setopt=install_weak_deps=False --nodocs \ # The toolchain: OTP from source and Elixir. FROM build-packages AS toolchain -COPY otp_src_28.5.0.7.tar.gz elixir-1.20.4-otp-28.zip hex-2.5.1-otp-28.ez rebar3-3.25.1-otp-28 /build/resources/ +COPY otp_src_28.5.0.8.tar.gz elixir-1.20.4-otp-28.zip hex-2.5.1-otp-28.ez rebar3-3.25.1-otp-28 /build/resources/ # --enable-fips compiles the crypto NIF's FIPS support, and --with-ssl links UBI's OpenSSL # dynamically, which is where Red Hat's FIPS provider lives (docs/fips-leg.md). Applications with # no place in a server are left out of the build. WORKDIR /build/otp -RUN tar -xzf /build/resources/otp_src_28.5.0.7.tar.gz --strip-components=1 \ +RUN tar -xzf /build/resources/otp_src_28.5.0.8.tar.gz --strip-components=1 \ && ./configure --prefix=/usr/local --enable-fips --with-ssl=/usr \ --without-javac --without-jinterface --without-wx --without-odbc \ --without-debugger --without-observer --without-et --without-megaco \ diff --git a/ci/ironbank/hardening_manifest.yaml b/ci/ironbank/hardening_manifest.yaml index 68362e7..e5c71c8 100644 --- a/ci/ironbank/hardening_manifest.yaml +++ b/ci/ironbank/hardening_manifest.yaml @@ -50,11 +50,11 @@ resources: # Erlang/OTP, built from source in the builder with --enable-fips against UBI's OpenSSL. The # version is the container's, `ci/container.tool-versions`, which can run ahead of the desktop's # `.tool-versions` (ADR-0014); the lint holds the two files and this resource together. - - url: "https://github.com/erlang/otp/releases/download/OTP-28.5.0.7/otp_src_28.5.0.7.tar.gz" - filename: "otp_src_28.5.0.7.tar.gz" + - url: "https://github.com/erlang/otp/releases/download/OTP-28.5.0.8/otp_src_28.5.0.8.tar.gz" + filename: "otp_src_28.5.0.8.tar.gz" validation: type: "sha256" - value: "ddf17db6d3e9b7a7cfac0d72238ddc8ea040fedc5e3dfad82fc90675319d6c93" + value: "8a696e15bcbe5d57197b1f4c03c92d415f9fd9af70f9222a05911c8a5463cba6" # Elixir, precompiled for OTP 28 (BEAM files only, no native code). - url: "https://github.com/elixir-lang/elixir/releases/download/v1.20.4/elixir-otp-28.zip" filename: "elixir-1.20.4-otp-28.zip" diff --git a/config/runtime.exs b/config/runtime.exs index 1eb653b..3d4dee5 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -34,7 +34,7 @@ headless? = System.get_env("TRINITY_MODE") == "headless" # Owner decision D2 (ADR-0014): on an OTP whose `ssl` carries CVE-2026-89422 every outbound TLS # client is held to TLS 1.2, configured here so it is in place before any application starts # (ReqLLM reads its pool configuration when it starts). `Trinity.TLS` decides from the `ssl` the -# runtime loads and returns nothing on a fixed runtime, so the container images, on 28.5.0.7, are +# runtime loads and returns nothing on a fixed runtime, so the container images, on 28.5.0.8, are # configured exactly as before. This calls a project module, which the note on `smoke?` above # says Mix does not allow; it holds for `mix run`, `mix test` and a release alike, measured # 2026-10-08 (test/trinity/tls_clamp_test.exs runs against what this sets), and `--smoke` is not diff --git a/docs/07-security-model.md b/docs/07-security-model.md index cfed16d..100720b 100644 --- a/docs/07-security-model.md +++ b/docs/07-security-model.md @@ -546,6 +546,31 @@ fail-open. Where it signs today: a runtime linked to OpenSSL 3.5 or later with t mode, on the image's current FIPS provider, it does not; a provider validated with ML-DSA-87 (CMVP #5523) is what would change that. +## The headless release's floor, and the decoder asked directly (Slice 141, as built) + +The headless release, the one deployed as a server, is held to OTP 28.5.0.8 and to the OTP 28 line +only; the desktop keeps the floor above, because its runtime is the one Burrito can fetch +(ADR-0014, amended). 28.5.0.8 fixes CVE-2026-94200 and CVE-2026-105236 in ssl (a TLS 1.3 client's +ticket store; a session resumed under another SNI name), CVE-2026-42789 in public_key (a v1 or v2 +certificate accepted as a CA) and CVE-2026-48856 in inets (credentials sent on to a cross-origin +redirect). Those fixes are in the applications, and erts-16.4.0.7 can be installed without them +(erlang.org: it "can be applied independently of other applications on a full OTP 28 +installation"), so the check is again every loaded application against that release's row of +`otp_versions.table`, from the same commit as the rows above. A node is held to it when its release name is `headless` or it was +started with `TRINITY_MODE=headless`; either is enough, so leaving one unset does not lower it. +Any other OTP line is refused, above as well as below. + +Every node also asks the decoder instead of trusting a version string. `Trinity.Toolchain.check_decoder/1` +decodes a `LARGE_TUPLE_EXT` of arity 2^24, one over erts's tuple limit, which the CVE-2026-55737 +fix refuses (`decoded_size()` in erts's `external.c`), and a small tuple of the same encoding, which +every runtime decodes, so a decoder that refuses everything is not taken for a fixed one. Measured +on 2026-10-20: OTP 28.1.1 builds the 16,777,216-element tuple in 82 ms; 28.5.0.5 to 28.5.0.8 and +29.1 refuse it in about 20 microseconds. It runs in a process of its own, so neither the 16 MiB +probe nor what an unfixed runtime builds from it stays on the booting process. + +Not covered: both checks run when the application starts, so `bin/headless eval`, which runs code +in the release without starting it, is not held to them. + ## Signed statements in one byte form (Slice 138, as built) The rule: every statement Trinity constructs and signs is deterministic CBOR inside COSE_Sign1, diff --git a/docs/adr/0014-otp-pins-split.md b/docs/adr/0014-otp-pins-split.md index 862b81d..3a539c8 100644 --- a/docs/adr/0014-otp-pins-split.md +++ b/docs/adr/0014-otp-pins-split.md @@ -123,3 +123,39 @@ pull request per version. connections by application, and Req's pooled connections now belong to Trinity's supervisor; it leaves `Trinity.TLS.Finch` out, as it left `Req.Finch` out before. The desktop pin moving to the container's ends the first of these. + +## Amendment, 2026-10-20 (slice 141): the headless release has a floor of its own + +Status: proposed; the owner accepts or rejects it. + +The Decision above keeps two pins and held every node to one floor, CVE-2026-55737's (slice 137: +OTP 27.3.4.15, 28.5.0.4 or 29.0.4). This amendment adds a second floor and ties it to the release, +not to the pin. + +- **The headless release refuses to boot below OTP 28.5.0.8, and on any line but OTP 28** + (`Trinity.Toolchain.floor(:headless)`, the OTP-28.5.0.8 row of `otp_versions.table`). A node is + held to it when its release name is `headless` or it runs with `TRINITY_MODE=headless`. The + desktop release keeps CVE-2026-55737's floor. +- **The container pin moves to 28.5.0.8 with it**, so the image the headless release is built in + meets its floor. The lint's rules are unchanged: one major for both pins, and the container's + never older than the desktop's. +- **The desktop pin still moves only by the canary** (ADR-0005). The canary probes for the + container's pin, so it now waits for Burrito's 28.5.0.8 on all three targets. + +**Why the release and not the pin.** The floor belongs to the node that serves the network. A +headless node started from source on the desktop pin is still that node, so it is refused there too +and needs 28.5.0.8 installed; a desktop node on the container's OTP is held only to the desktop +floor. + +**Consequences.** + +- Until the canary flips, the desktop runs 28.5.0.6 and carries the four CVEs 28.5.0.8 fixes. + CVE-2026-94200 needs a TLS 1.3 client with `session_tickets => auto`; nothing in the tree or its + dependencies sets that option, and the clamp holds every client to TLS 1.2. CVE-2026-105236 is a + TLS server's, and the desktop serves plain HTTP on loopback. CVE-2026-42789 (a v1 or v2 + certificate accepted as a CA during path validation) reaches every TLS client and is not + mitigated. CVE-2026-48856 reaches an `httpc` request whose URL carries `user:password@`; the + tree's `httpc` callers (model downloads, an OIDC issuer's configuration, JSON Schema resolution) + take their URLs from configuration, so it is reached only where an operator configures one. +- Both floors are checked when the application starts. `bin/headless eval` runs code in the release + without starting it, so it is held to neither. diff --git a/docs/fips-leg.md b/docs/fips-leg.md index 6355ff9..b7232c9 100644 --- a/docs/fips-leg.md +++ b/docs/fips-leg.md @@ -12,7 +12,7 @@ name the command that produced them. | Piece | Where | What it does | |---|---|---| -| `ci/fips/Containerfile` | the image | UBI9 by digest; OTP (the version `ci/container.tool-versions` names, 28.5.0.7; ADR-0014) from the release tarball with `--enable-fips --with-ssl=/usr`; Elixir 1.20.4 from the release zip; both archives checked against SHA-256 digests pinned in the file; git, python3 with PyYAML, gcc and procps for the gate's own steps | +| `ci/fips/Containerfile` | the image | UBI9 by digest; OTP (the version `ci/container.tool-versions` names, 28.5.0.8; ADR-0014) from the release tarball with `--enable-fips --with-ssl=/usr`; Elixir 1.20.4 from the release zip; both archives checked against SHA-256 digests pinned in the file; git, python3 with PyYAML, gcc and procps for the gate's own steps | | `.github/workflows/fips-image.yml` | builds the image | On a change to `.tool-versions` (Elixir), `ci/container.tool-versions` (OTP), the Containerfile or the tag script, and on demand; pushes to `ghcr.io/scriptkittyos/trinity-fips` under the tag below; a tag already published is not rebuilt | | `scripts/fips_image_tag.sh` | both workflows | The tag: sixteen hex digits of SHA-256 over `.tool-versions`, `ci/container.tool-versions` and the Containerfile, so the builder and the puller cannot disagree | | `.github/workflows/gate.yml`, jobs `fips-tag` and `fips` | the leg | Pulls the image, fetches and compiles dependencies with the mode off, asserts the mode, checks the mode-off listing, then runs `plan_check`, the DCO check and `mix gate`, all in the mode | @@ -118,7 +118,7 @@ owner. No skip tag exists for this: `git grep -n '@tag :skip\|@moduletag :skip' ## Reproducing the image and the leg on a machine ``` -docker build --build-arg OTP_VERSION=28.5.0.7 --build-arg ELIXIR_VERSION=1.20.4 \ +docker build --build-arg OTP_VERSION=28.5.0.8 --build-arg ELIXIR_VERSION=1.20.4 \ -t trinity-fips:local -f ci/fips/Containerfile ci/fips docker run --rm -v "$PWD:/work" -w /work \ -e MIX_ENV=test -e TRINITY_DB=sqlite -e TRINITY_FIPS_LEG=1 trinity-fips:local bash -c ' diff --git a/docs/regulated/control-mapping.md b/docs/regulated/control-mapping.md index b86c236..ae0efdb 100644 --- a/docs/regulated/control-mapping.md +++ b/docs/regulated/control-mapping.md @@ -124,7 +124,7 @@ register does not claim is never Trinity's. | `SC-24` | Failure denies: an unavailable signer denies every effect, a tool absent from the catalogue or a decision other than allow is denied before anything runs, and an authority adapter that cannot be loaded refuses the boot | `lib/trinity/effects.ex`, `lib/trinity/authority/selection.ex` | `test/trinity/effects/membrane_test.exs` "a :catalog tool absent from the catalog, or a decision other than allow, is denied before anything runs"; `test/trinity/authority/selection_test.exs` "an absent module is refused as not loaded, by name" | Trinity's | none | Register "Nothing fails open": :unknown | | `SC-28` | Anything SQLite indexes is left unencrypted by design, for the volume beneath it | `docs/encryption-at-rest.md` | none | the deployment's | Volume or page-level encryption below SQLite | CRM "Encryption of everything SQLite indexes" | | `SC-28(1)` | Blobs nothing indexes (skill files, staged changes, archives) are sealed with AES-256-GCM under a fresh data key each | `lib/trinity/vault.ex` | `test/trinity/vault_test.exs` "a sealed blob is ciphertext: the plaintext does not appear in it"; `test/trinity/vault_test.exs` "every blob gets its own data key, so one compromise does not open the next" | Trinity's | none | CRM "Envelope encryption of blobs"; Register "Encryption of data at rest with deployment-controlled keys": :unknown | -| `SI-2` | Every profile refuses to boot, before any child starts, on a runtime below the release that fixed CVE-2026-55737 for its line (OTP 27.3.4.15, 28.5.0.4 or 29.0.4): erts and every loaded application are compared with that release's row of `otp_versions.table`, checked in verbatim with its source commit and SHA-256. Elixir outside 1.20.x is refused the same way, and the boot receipt records the erts and Elixir it ran on | `lib/trinity/toolchain.ex`, `priv/otp_floor.table`, `lib/trinity/application.ex`, `lib/trinity/effects/boot.ex` | `Trinity.Toolchain.check_otp/3`; `Trinity.Toolchain.check_otp/2`; `Trinity.Toolchain.check_elixir/1`; `test/trinity/toolchain_node_test.exs` "the line the child prints names the refusal, the runtime's erts and the floor"; `test/trinity/toolchain_test.exs` "erts at the floor with an application below it refuses, naming the application"; `test/trinity/toolchain_node_test.exs` "a default node boots" | Trinity's | none | none | +| `SI-2` | Every profile refuses to boot, before any child starts, on a runtime below the release that fixed CVE-2026-55737 for its line (OTP 27.3.4.15, 28.5.0.4 or 29.0.4); the headless release refuses below OTP 28.5.0.8 and on every line but OTP 28 (Slice 141). erts and every loaded application are compared with that release's row of `otp_versions.table`, checked in verbatim with its source commit and SHA-256, and the decoder is asked directly: a tuple one element over erts's limit, which the CVE-2026-55737 fix refuses, must be refused. Elixir outside 1.20.x is refused the same way, and the boot receipt records the erts and Elixir it ran on | `lib/trinity/toolchain.ex`, `priv/otp_floor.table`, `lib/trinity/application.ex`, `lib/trinity/effects/boot.ex` | `Trinity.Toolchain.check_otp/2`; `Trinity.Toolchain.check_otp/3`; `Trinity.Toolchain.check_otp/4`; `Trinity.Toolchain.check_decoder/0`; `Trinity.Toolchain.check_decoder/1`; `Trinity.Toolchain.check_elixir/1`; `test/trinity/toolchain_node_test.exs` "the line the child prints names the refusal, the runtime's erts and the floor"; `test/trinity/toolchain_test.exs` "erts at the floor with an application below it refuses, naming the application"; `test/trinity/toolchain_node_test.exs` "a default node boots"; `test/trinity/toolchain_node_test.exs` "a headless node on this suite's runtime boots only at 28.5.0.8 or later on OTP 28"; `test/trinity/toolchain_test.exs` "28.5.0.8 passes; 28.5.0.7 refuses, naming erts and every application below the row"; `test/trinity/toolchain_test.exs` "erts at the floor with ssl below it refuses, naming ssl"; `test/trinity/toolchain_test.exs` "a decoder that builds the oversize tuple is refused, naming its arity" | Trinity's | none | none | | `SI-4` | Documented telemetry events an operator can subscribe to; Trinity emits them and alerts no one | `lib/trinity/telemetry.ex`, `docs/telemetry.md` | `test/trinity/telemetry/telemetry_test.exs` "every event the catalogue lists appears in docs/telemetry.md" | shared | Collecting the events, and alerting on them | CRM "Telemetry events an operator can subscribe to" | | `SI-7` | A changed MCP tool definition is held, not called, until the owner accepts it; the first sighting is the baseline | `lib/trinity/tools/surface.ex`, `lib/trinity/tools/definition_digest.ex` | `test/trinity/mcp/surface_drift_test.exs` "a description change alone holds the tool, with the schema untouched" | Trinity's | none | none | | `SI-7` | Every input to the image build that is not a UBI package is declared with its digest and copied only if declared, and the published bases are pinned by digest | `ci/ironbank/hardening_manifest.yaml`, `lib/mix/tasks/trinity.ironbank.lint.ex` | `mix trinity.ironbank.lint`; `test/ironbank_submission_test.exs` "copying a file no resource declares is refused"; `test/ironbank_submission_test.exs` "a published base pinned by tag and not by digest is refused (planted red)" | Trinity's | none | none | diff --git a/docs/regulated/headless-image.md b/docs/regulated/headless-image.md index e32278a..fc37238 100644 --- a/docs/regulated/headless-image.md +++ b/docs/regulated/headless-image.md @@ -14,6 +14,12 @@ laid out the way their pipeline reads it, but it has not been submitted, reviewe authorization.** The STIG applicability statement below is a statement about one image; whether a system that runs it is compliant is decided for that system by whoever authorizes it. +**The image is this release's hardening and CI artifact, not the only place it runs.** A +deployment that builds its own appliance image carries the headless release in it, with that +image's OpenSSL and FIPS provider. The release's own boot checks go with it: the OTP floor +(OTP 28.5.0.8 for this release, `Trinity.Toolchain`) and, where ML-DSA-87 receipts are configured, +the known-answer preflight. Which provider such an image loads is that image's to state. + ## One recipe, two artifacts `ci/ironbank/Dockerfile` is the only recipe for the headless image. It is built two ways: @@ -50,7 +56,7 @@ network, playing the part of Iron Bank's prebuild step; it is not part of the su `openssl-libs`, which brings the CA trust store with it as `ca-certificates`), installed with `dnf --installroot` so the image's rpm database stays true to what is on disk. No package manager, compiler or shell history. -* **Erlang/OTP 28**, the version `ci/container.tool-versions` names (28.5.0.7), built from source +* **Erlang/OTP 28**, the version `ci/container.tool-versions` names (28.5.0.8), built from source with FIPS support against that OpenSSL (`docs/fips-leg.md`). The container's OTP is pinned apart from the desktop's, which waits for Burrito's builds; `docs/adr/0014-otp-pins-split.md` says why. * **The Trinity release**, under `/opt/trinity`, owned by root and not writable at run time. @@ -128,11 +134,13 @@ of it against repo1's intake requirements, are the maintainer's. ## Known gaps, stated -* **The OTP runtime's advisories are fixed, not justified.** The image builds OTP 28.5.0.7, and - grype, which catalogs `erlang 28.5.0.7` from the runtime in the image, reports no advisory against - it. Measured on 2026-10-08 with one vulnerability database (built 2026-10-08T06:33:47Z) against both - images: the previous build, on 28.5.0.5, had 14 High or Critical findings in the runtime, among - them CVE-2026-89422 in `ssl`; this one has none. The desktop build runs an older OTP for now +* **The OTP runtime's advisories are fixed, not justified.** The image builds OTP 28.5.0.8 (Slice + 141), the floor the headless release refuses to boot below (`Trinity.Toolchain`). On 2026-10-08, + with one vulnerability database (built 2026-10-08T06:33:47Z), grype reported no advisory against + the runtime of the 28.5.0.7 build, which had replaced a 28.5.0.5 build with 14 High or Critical + findings in the runtime, among them CVE-2026-89422 in `ssl`. 28.5.0.8 fixes four more (ssl, + public_key and inets); the image workflow scans every build, and a finding without a + justification fails it. The desktop build runs an older OTP for now (`docs/adr/0014-otp-pins-split.md`); this image does not depend on it. * **The release still carries the local embedder** (`exla` and the XLA library, `tokenizers`) and some build-time libraries. Removing them from the regulated image is a change to what the release diff --git a/lib/trinity/application.ex b/lib/trinity/application.ex index a5f1dfe..1052d01 100644 --- a/lib/trinity/application.ex +++ b/lib/trinity/application.ex @@ -33,7 +33,9 @@ defmodule Trinity.Application do # Slice 137, in every profile, before every other refusal and every child: a runtime below # the CVE-2026-55737 floor, or an Elixir below 1.20, is not one this node trusts to decode - # anything. The floor is erts per OTP line (`Trinity.Toolchain.floor/0`). + # anything. The floor is erts and every loaded application per OTP line + # (`Trinity.Toolchain.floor/1`). Slice 141: the headless release is held to OTP 28.5.0.8 on + # the OTP 28 line only, and every node asks its decoder directly as well. verify_toolchain!() # Slice 135, in every profile: a filesystem root that is, contains or sits inside the data diff --git a/lib/trinity/tls.ex b/lib/trinity/tls.ex index 5ad55fd..35d130e 100644 --- a/lib/trinity/tls.ex +++ b/lib/trinity/tls.ex @@ -9,8 +9,8 @@ defmodule Trinity.TLS do checking the server's certificate when the ServerHello carries a `pre_shared_key` the client never offered, so anyone on the path can impersonate a model provider or an MCP server. Its workaround is `{versions, ['tlsv1.2']}`, and it says no configuration keeps TLS 1.3 and - mitigates the issue. The fix is in OTP 28.5.0.7. The container images build that from - source; the desktop runs the ERTS Burrito downloads, which did not exist for 28.5.0.7 on Linux + mitigates the issue. The fix is in OTP 28.5.0.7. The container images build OTP from source + (28.5.0.8 since slice 141); the desktop runs the ERTS Burrito downloads, which did not exist for 28.5.0.7 on Linux or macOS when this was written, so the desktop runs 28.5.0.6 with this clamp. ## The decision is the running runtime's @@ -19,7 +19,7 @@ defmodule Trinity.TLS do and the one fact every runtime carries: an assembled release has no `OTP_VERSION` file (slice 130 NOTES, D-130-11). On OTP 28 the fix is `ssl` 11.6.0.6 (`otp_versions.table`, OTP-28.5.0.7). On a major this module has no fixed version for, it clamps. So the container on - 28.5.0.7 keeps TLS 1.3, the desktop on 28.5.0.6 clamps, and the clamp lifts itself when the + 28.5.0.8 keeps TLS 1.3, the desktop on 28.5.0.6 clamps, and the clamp lifts itself when the desktop's pin moves. ## Three layers, because one is not enough diff --git a/lib/trinity/toolchain.ex b/lib/trinity/toolchain.ex index fbc6161..9a26f4e 100644 --- a/lib/trinity/toolchain.ex +++ b/lib/trinity/toolchain.ex @@ -2,59 +2,131 @@ # SPDX-License-Identifier: Apache-2.0 defmodule Trinity.Toolchain do @moduledoc """ - The runtime floor Trinity refuses to boot below (slice 137). + The runtime floor Trinity refuses to boot below (slice 137), and the headless release's + stricter one (slice 141). - The floor is "OTP 28.5.0.4 or later, or the 27.3.4.15 or 29.0.4 lines": the releases that fix - CVE-2026-55737, "heap corruption when an invalidly encoded tuple with an arity of 2^31 or larger - is decoded from Erlang's External Term Format" (OTP-20214, erts). The same releases fix CVEs in - ssl, public_key, crypto, ssh and stdlib, so erts alone does not say a runtime is at the floor. + ## Two floors + + **Every node** is held to "OTP 28.5.0.4 or later, or the 27.3.4.15 or 29.0.4 lines": the releases + that fix CVE-2026-55737, "heap corruption when an invalidly encoded tuple with an arity of 2^31 or + larger is decoded from Erlang's External Term Format" (OTP-20214, erts). The same releases fix CVEs + in ssl, public_key, crypto, ssh and stdlib, so erts alone does not say a runtime is at the floor. + The desktop is held to this one: its runtime is the one Burrito can fetch for every target + (ADR-0005, ADR-0014). + + **The headless release** (`kind/1`), the one deployed as a server, is held to OTP 28.5.0.8 on the + OTP 28 line, and refuses every other line, above as well as below. 28.5.0.8 fixes CVE-2026-94200 + and CVE-2026-105236 in ssl (a TLS 1.3 client's ticket store; a session resumed under another SNI + name), CVE-2026-42789 in public_key (a v1 or v2 certificate accepted as a CA) and CVE-2026-48856 + in inets (credentials sent on to a cross-origin redirect). Those fixes are in the applications; + erts-16.4.0.7, whose own change is a trace fix, can be installed without them (erlang.org: it + "can be applied independently of other applications on a full OTP 28 installation"), so here too + erts alone does not say a runtime is at the floor. ## What is compared - `priv/otp_floor.table` holds the three rows of erlang/otp's `otp_versions.table`, verbatim, with + `priv/otp_floor.table` holds the four rows of erlang/otp's `otp_versions.table`, verbatim, with the commit and SHA-256 they came from. Each row lists every application at its version in that - release. A runtime is at the floor for its line when erts and every loaded application the row + release. A runtime is at a floor for its line when erts and every loaded application the row names are at or above the row's version. erts comes from `:erlang.system_info(:version)` and the applications from `:application.loaded_applications/0`, both of which answer in an assembled release, which carries no `OTP_VERSION` file (slice 130, D-130-11). By the time `Trinity.Application.start/2` asks, every application Trinity depends on is loaded. - A major above the last row passes: fixes land forward into later majors. A major below the first - row refuses: no fixed release exists for it. + Under the every-node floor, a major above the last row passes (fixes land forward into later + majors) and a major below the first row refuses (no fixed release exists for it). + + ## The decoder, asked directly + + A version is what a runtime says it is. `check_decoder/1` asks the decoder itself: it decodes a + tuple one element over erts's limit (`ERTS_MAX_TUPLE_SIZE`, 2^24 - 1), which the CVE-2026-55737 + fix refuses in `decoded_size()` (erts/emulator/beam/external.c, OTP-28.5.0.4) and an unfixed + runtime builds, and a small tuple of the same encoding, which every runtime decodes. Measured on + 2026-10-20: OTP 28.1.1 builds the 16,777,216-element tuple (82 ms); 28.5.0.5 to 28.5.0.8 and 29.1 + refuse it in about 20 microseconds. - Elixir must be 1.20.x (`~> 1.20.0`), as the shared contract states it for every box release. + ## The fault seam + + `config :trinity, :decoder_probe_fault, true` makes `check/1` probe a decoder that accepts every + input, so a boot test can show the probe is asked at boot on a runtime whose decoder is fixed. Like + `Trinity.Crypto.Preflight`'s seam, it can only turn a pass into a refusal, never the other way. + + Elixir must be 1.20.x (`~> 1.20.0`) on every node. """ + Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) + @table Path.join([__DIR__, "..", "..", "priv", "otp_floor.table"]) |> Path.expand() @external_resource @table - @rows (for line <- File.read!(@table) |> String.split("\n"), - String.starts_with?(line, "OTP-"), - into: %{} do - [release, apps] = String.split(line, " : ", parts: 2) - "OTP-" <> version = release - major = version |> String.split(".") |> hd() |> String.to_integer() + # Every row in the table, by release. + @releases (for line <- File.read!(@table) |> String.split("\n"), + String.starts_with?(line, "OTP-"), + into: %{} do + [release, apps] = String.split(line, " : ", parts: 2) + "OTP-" <> version = release - versions = - for entry <- String.split(apps, [" ", "#", ":"], trim: true), - [app, vsn] <- [String.split(entry, "-", parts: 2)], - into: %{}, - do: {String.to_atom(app), vsn} + versions = + for entry <- String.split(apps, [" ", "#", ":"], trim: true), + [app, vsn] <- [String.split(entry, "-", parts: 2)], + into: %{}, + do: {String.to_atom(app), vsn} - {major, %{release: version, apps: versions}} + {version, %{release: version, apps: versions}} + end) + + # The releases each floor is made of, and what it says of a major with no row: the every-node + # floor passes a major above its last row; the headless floor refuses any major it has no row for. + @floors %{ + desktop: {["27.3.4.15", "28.5.0.4", "29.0.4"], :pass_above}, + headless: {["28.5.0.8"], :refuse_others} + } + + @rows (for {kind, {releases, _}} <- @floors, into: %{} do + {kind, + Map.new(releases, fn release -> + major = release |> String.split(".") |> hd() |> String.to_integer() + {major, Map.fetch!(@releases, release)} + end)} end) + # LARGE_TUPLE_EXT (105) with arity 2^24, one over ERTS_MAX_TUPLE_SIZE, then that many NIL_EXT + # (106); and the control, the same encoding at arity 256. + @oversize 16_777_216 + @control 256 + @elixir_requirement "~> 1.20.0" @type finding :: {atom(), String.t(), String.t()} + @type kind :: :desktop | :headless @type reason :: {:otp_below_floor, non_neg_integer(), String.t(), [finding()]} | {:otp_major_unsupported, non_neg_integer(), String.t()} | {:elixir_outside_floor, String.t(), String.t()} + | {:decoder_accepts_oversize_tuple, pos_integer()} + | {:decoder_control_failed, pos_integer()} + | {:decoder_probe_failed, term()} - @doc "The floor rows, by OTP major: the fixing release and every application's version in it." - @spec floor() :: %{non_neg_integer() => %{release: String.t(), apps: %{atom() => String.t()}}} - def floor, do: @rows + @doc """ + A floor's rows, by OTP major: the fixing release and every application's version in it. The + every-node floor (`:desktop`) by default. + """ + @spec floor(kind()) :: + %{non_neg_integer() => %{release: String.t(), apps: %{atom() => String.t()}}} + def floor(kind \\ :desktop) when is_map_key(@rows, kind), do: Map.fetch!(@rows, kind) + + @doc """ + The floor a node is held to: `:headless` when the process is the headless release, by its release + name (`RELEASE_NAME`, which the release's own script exports) or by the mode it was started in + (`TRINITY_MODE`, which the image sets); `:desktop` otherwise. Either signal is enough, so a + headless node is never held to the weaker floor because one of the two was left unset. + """ + @spec kind(%{String.t() => String.t()}) :: kind() + def kind(env) when is_map(env) do + if env["RELEASE_NAME"] == "headless" or env["TRINITY_MODE"] == "headless", + do: :headless, + else: :desktop + end @doc "The Elixir requirement every node must meet." @spec elixir_requirement() :: String.t() @@ -79,24 +151,26 @@ defmodule Trinity.Toolchain do @doc """ Whether a runtime on OTP major `major`, with erts `erts` and the loaded applications `apps`, is at - the floor. A refusal lists every component below its row's version, erts first. + floor `kind`. A refusal lists every component below its row's version, erts first. """ - @spec check_otp(non_neg_integer(), String.t(), %{atom() => String.t()}) :: + @spec check_otp(non_neg_integer(), String.t(), %{atom() => String.t()}, kind()) :: :ok | {:error, reason()} - def check_otp(major, erts, apps \\ %{}) when is_integer(major) and is_binary(erts) do - {first, last} = Enum.min_max(Map.keys(@rows)) + def check_otp(major, erts, apps \\ %{}, kind \\ :desktop) + when is_integer(major) and is_binary(erts) and is_map_key(@floors, kind) do + rows = Map.fetch!(@rows, kind) + {_, others} = Map.fetch!(@floors, kind) cond do - major > last -> - :ok + Map.has_key?(rows, major) -> + compare_row(major, Map.fetch!(rows, major), [ + {:erts, erts} | apps |> Map.delete(:erts) |> Enum.sort() + ]) - major < first -> - {:error, {:otp_major_unsupported, major, erts}} + others == :pass_above and major > rows |> Map.keys() |> Enum.max() -> + :ok true -> - compare_row(major, Map.fetch!(@rows, major), [ - {:erts, erts} | apps |> Map.delete(:erts) |> Enum.sort() - ]) + {:error, {:otp_major_unsupported, major, erts}} end end @@ -120,12 +194,83 @@ defmodule Trinity.Toolchain do else: {:error, {:elixir_outside_floor, version, @elixir_requirement}} end - @doc "Both checks against the running runtime, the OTP floor first." + @doc "The probe `check_decoder/1` decodes: a tuple one element over erts's limit, encoded." + @spec decoder_probe() :: binary() + def decoder_probe, do: large_tuple(@oversize) + + @doc """ + Whether the decoder refuses a tuple one element over erts's limit, and still decodes a small one + of the same encoding, so that a decoder refusing everything is not read as fixed. `decode` is + `:erlang.binary_to_term/1` except where a test plants another. + + It runs in a process of its own, so the 16 MiB probe, and the 128 MiB tuple an unfixed runtime + builds from it, are freed when the answer comes back and are never on the booting process's heap. + A probe that fails in any other way is a refusal too, never a wait and never a pass. + """ + @spec check_decoder((binary() -> term())) :: :ok | {:error, reason()} + def check_decoder(decode \\ erlang_decoder()) when is_function(decode, 1) do + {_pid, ref} = spawn_monitor(fn -> exit({:answer, decoder_answer(decode)}) end) + + receive do + {:DOWN, ^ref, :process, _, {:answer, answer}} -> answer + {:DOWN, ^ref, :process, _, reason} -> {:error, {:decoder_probe_failed, reason}} + end + end + + defp decoder_answer(decode) do + cond do + not refuses?(decode, large_tuple(@oversize)) -> + {:error, {:decoder_accepts_oversize_tuple, @oversize}} + + not control_decodes?(decode) -> + {:error, {:decoder_control_failed, @control}} + + true -> + :ok + end + end + + defp refuses?(decode, bin) do + _ = decode.(bin) + false + rescue + ArgumentError -> true + end + + defp control_decodes?(decode) do + term = decode.(large_tuple(@control)) + is_tuple(term) and tuple_size(term) == @control + rescue + ArgumentError -> false + end + + # sobelow_skip reason: Misc.BinToTerm: the decoder is only ever handed `large_tuple/1` of two + # constant arities, bytes this module builds; nothing from outside reaches it. Decoding them is + # the check. + @sobelow_skip ["Misc.BinToTerm"] + defp erlang_decoder, do: &:erlang.binary_to_term/1 + + defp large_tuple(arity), do: <<131, 105, arity::32, :binary.copy(<<106>>, arity)::binary>> + + @doc """ + Every check against the running runtime, for the floor `kind/1` reads from the process's + environment: the OTP floor, then the decoder, then Elixir. + """ @spec check() :: :ok | {:error, reason()} - def check do + def check, do: check(kind(System.get_env())) + + @doc "Every check against the running runtime, for floor `kind`." + @spec check(kind()) :: :ok | {:error, reason()} + def check(kind) do %{otp_release: major, erts: erts, elixir: elixir, apps: apps} = current() - with :ok <- check_otp(String.to_integer(major), erts, apps) do + decoder = + if Application.get_env(:trinity, :decoder_probe_fault) == true, + do: fn _ -> :planted end, + else: erlang_decoder() + + with :ok <- check_otp(String.to_integer(major), erts, apps, kind), + :ok <- check_decoder(decoder) do check_elixir(elixir) end end diff --git a/lib/trinity/versions.ex b/lib/trinity/versions.ex index 4bf1fb8..3b01250 100644 --- a/lib/trinity/versions.ex +++ b/lib/trinity/versions.ex @@ -65,11 +65,11 @@ defmodule Trinity.Versions do }, %{ name: "Erlang/OTP (container images)", - pin: "**28.5.0.7**", + pin: "**28.5.0.8**", lock: nil, - from: {:file, "ci/container.tool-versions", "erlang 28.5.0.7"}, + from: {:file, "ci/container.tool-versions", "erlang 28.5.0.8"}, note: - "Added 2026-10-08 (owner decision D2, ADR-0014): the OTP the hardened headless image (`ci/ironbank/`) and the FIPS leg's image (`ci/fips/Containerfile`) build from source, so it does not wait for Burrito. 28.5.0.7 (released 2026-09-22) fixes CVE-2026-89422 (ssl, Critical) and CVE-2026-65634 (asn1, High). The source archive's SHA-256, `ddf17db6d3e9b7a7cfac0d72238ddc8ea040fedc5e3dfad82fc90675319d6c93`, was read from the release's SHA256.txt and its asset digest and matched a download. `mix trinity.ironbank.lint` holds the manifest to this file, and this pin to the desktop's major and to no older than it." + "Added 2026-10-08 (owner decision D2, ADR-0014): the OTP the hardened headless image (`ci/ironbank/`) and the FIPS leg's image (`ci/fips/Containerfile`) build from source, so it does not wait for Burrito. Moved from 28.5.0.7 to 28.5.0.8 on 2026-10-20 (slice 141), the headless release's floor (`Trinity.Toolchain`): 28.5.0.8 (released 2026-10-14) fixes CVE-2026-94200 and CVE-2026-105236 (ssl), CVE-2026-42789 (public_key) and CVE-2026-48856 (inets). The source archive's SHA-256, `8a696e15bcbe5d57197b1f4c03c92d415f9fd9af70f9222a05911c8a5463cba6`, was read from the release's SHA256.txt and its asset digest and matched a download. `mix trinity.ironbank.lint` holds the manifest to this file, and this pin to the desktop's major and to no older than it." }, %{ name: "Elixir", diff --git a/priv/otp_floor.table b/priv/otp_floor.table index 6611d22..285e253 100644 --- a/priv/otp_floor.table +++ b/priv/otp_floor.table @@ -1,9 +1,12 @@ # SPDX-FileCopyrightText: Ericsson AB # SPDX-License-Identifier: Apache-2.0 # -# Slice 137. The three rows of erlang/otp's otp_versions.table that fix CVE-2026-55737, verbatim, -# read by Trinity.Toolchain at compile time. Source: https://raw.githubusercontent.com/erlang/otp/499d6a4b8bf8e145f33b7b7fc8a6b11de9e7bda2/otp_versions.table +# Rows of erlang/otp's otp_versions.table, verbatim, read by Trinity.Toolchain at compile time. +# Slice 137: the three releases that fix CVE-2026-55737 (OTP-29.0.4, OTP-28.5.0.4, OTP-27.3.4.15). +# Slice 141: OTP-28.5.0.8, the headless release's floor, from the same file. +# Source: https://raw.githubusercontent.com/erlang/otp/499d6a4b8bf8e145f33b7b7fc8a6b11de9e7bda2/otp_versions.table # (commit 499d6a4b8bf8e145f33b7b7fc8a6b11de9e7bda2, file SHA-256 8990c38937ba7bb91ac206e659ba5f87289a41f3d69f58ad48f1e6dff5fee197). OTP-29.0.4 : compiler-10.0.3 crypto-5.9.2 diameter-2.7.2 erts-17.0.4 megaco-4.9.1 public_key-1.21.4 ssh-6.0.3 ssl-11.7.4 stdlib-8.0.3 # asn1-5.5 common_test-1.31.1 debugger-7.0 dialyzer-6.0.2 edoc-1.5 eldap-1.3 erl_interface-5.8.1 et-1.8 eunit-2.11 ftp-1.2.6 inets-9.7.1 jinterface-1.16 kernel-11.0.3 mnesia-4.26.1 observer-2.19 odbc-2.17 os_mon-2.12 parsetools-2.8 reltool-1.1 runtime_tools-2.4 sasl-4.4 snmp-5.20.4 syntax_tools-4.1 tftp-1.3 tools-4.2.1 wx-2.6 xmerl-2.2 : +OTP-28.5.0.8 : compiler-9.0.6.4 dialyzer-5.4.0.2 erts-16.4.0.7 inets-9.6.2.4 mnesia-4.25.3.4 odbc-2.16.1.1 public_key-1.20.3.6 snmp-5.20.2.3 ssh-5.5.2.7 ssl-11.6.0.7 # asn1-5.4.3.1 common_test-1.30.0.1 crypto-5.8.3.3 debugger-6.0.3 diameter-2.6.1.2 edoc-1.4.1 eldap-1.2.16.1 erl_interface-5.7.0.2 et-1.7.3 eunit-2.10.3 ftp-1.2.4.1 jinterface-1.15 kernel-10.6.3.4 megaco-4.8.3.2 observer-2.18.2 os_mon-2.11.2 parsetools-2.7.1 reltool-1.0.3 runtime_tools-2.3.1 sasl-4.3.2 stdlib-7.3.0.3 syntax_tools-4.0.3 tftp-1.2.4 tools-4.1.4.1 wx-2.5.4.1 xmerl-2.1.9 : OTP-28.5.0.4 : crypto-5.8.3.2 diameter-2.6.1.2 erts-16.4.0.4 megaco-4.8.3.1 public_key-1.20.3.4 ssh-5.5.2.3 ssl-11.6.0.4 stdlib-7.3.0.1 # asn1-5.4.3 common_test-1.30.0.1 compiler-9.0.6.1 debugger-6.0.3 dialyzer-5.4.0.1 edoc-1.4.1 eldap-1.2.16 erl_interface-5.7.0.1 et-1.7.3 eunit-2.10.3 ftp-1.2.4.1 inets-9.6.2.2 jinterface-1.15 kernel-10.6.3.3 mnesia-4.25.3.1 observer-2.18.2 odbc-2.16.1 os_mon-2.11.2 parsetools-2.7.1 reltool-1.0.3 runtime_tools-2.3.1 sasl-4.3.2 snmp-5.20.2.1 syntax_tools-4.0.3 tftp-1.2.4 tools-4.1.4 wx-2.5.4.1 xmerl-2.1.9 : OTP-27.3.4.15 : crypto-5.5.3.4 diameter-2.4.1.3 erts-15.2.7.11 megaco-4.7.2.2 public_key-1.17.1.5 ssh-5.2.11.10 ssl-11.2.12.11 stdlib-6.2.2.4 # asn1-5.3.4.2 common_test-1.27.7.1 compiler-8.6.1.5 debugger-5.5.0.1 dialyzer-5.3.1.1 edoc-1.3.2 eldap-1.2.14.1 erl_interface-5.5.2.1 et-1.7.1 eunit-2.9.1 ftp-1.2.3.1 inets-9.3.2.6 jinterface-1.14.1 kernel-10.2.7.4 mnesia-4.23.5.3 observer-2.17 odbc-2.15 os_mon-2.10.1 parsetools-2.6 reltool-1.0.1 runtime_tools-2.1.1 sasl-4.2.2.1 snmp-5.18.2 syntax_tools-3.2.2.2 tftp-1.2.2.1 tools-4.1.1 wx-2.4.3.1 xmerl-2.1.3.3 : diff --git a/test/ironbank_submission_test.exs b/test/ironbank_submission_test.exs index c6454fa..a489792 100644 --- a/test/ironbank_submission_test.exs +++ b/test/ironbank_submission_test.exs @@ -279,12 +279,12 @@ defmodule IronbankSubmissionTest do tree = %{ @tree | container_tool_versions: - String.replace(@tree.container_tool_versions, "erlang 28.5.0.7", "erlang 28.5.0.8") + String.replace(@tree.container_tool_versions, "erlang 28.5.0.8", "erlang 28.5.0.9") } assert_only( tree, - ~r/^AC6a: no resource otp_src_28\.5\.0\.8\.tar\.gz, the OTP 28\.5\.0\.8 that ci\/container\.tool-versions names/ + ~r/^AC6a: no resource otp_src_28\.5\.0\.9\.tar\.gz, the OTP 28\.5\.0\.9 that ci\/container\.tool-versions names/ ) end @@ -306,7 +306,7 @@ defmodule IronbankSubmissionTest do String.replace(@tree.tool_versions, "erlang 28.5.0.6", "erlang 27.3.4.18") } - assert_only(tree, ~r/^ADR-0014: the container's OTP 28\.5\.0\.7 .* are different majors/) + assert_only(tree, ~r/^ADR-0014: the container's OTP 28\.5\.0\.8 .* are different majors/) end test "a container OTP older than the desktop's is refused" do @@ -318,7 +318,7 @@ defmodule IronbankSubmissionTest do assert_only( tree, - ~r/^ADR-0014: the container's OTP 28\.5\.0\.7 .* is older than the desktop's 28\.5\.0\.10/ + ~r/^ADR-0014: the container's OTP 28\.5\.0\.8 .* is older than the desktop's 28\.5\.0\.10/ ) end diff --git a/test/test_helper.exs b/test/test_helper.exs index a1bd93f..890f99c 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -15,11 +15,15 @@ # :mldsa87_runtime (planted faults in the ML-DSA-87 known-answer check, which can only be seen # where the runtime signs) runs where the oracle says it does. That is derived, not declared, so # the decision is printed: a leg that loses ML-DSA-87 says so in its own log. +# Slice 141: :below_headless_floor_otp (a headless boot on OTP 28.5.0.7, below the headless +# release's floor) runs where TRINITY_BELOW_HEADLESS_FLOOR_OTP names that runtime's bin directory; +# excluded by tag elsewhere. # Slice 138: :n6_corpus (10^6 generated N6 bodies and their mutants) is opt-in, run with # `mix test --only n6_corpus`; it is a measurement, too long for every run. fips_leg? = System.get_env("TRINITY_FIPS_LEG") == "1" local_model? = System.get_env("TRINITY_LOCAL_MODEL_CACHE") not in [nil, ""] below_floor_otp? = System.get_env("TRINITY_BELOW_FLOOR_OTP") not in [nil, ""] +below_headless_floor_otp? = System.get_env("TRINITY_BELOW_HEADLESS_FLOOR_OTP") not in [nil, ""] mldsa87_runtime? = Trinity.TestSupport.MLDSA87Oracle.signs?() # A step that exists to run the :mldsa87_runtime tests sets this, so a runtime that lost ML-DSA-87 @@ -41,6 +45,7 @@ ExUnit.start( if(fips_leg?, do: [], else: [:fips]) ++ if(local_model?, do: [], else: [:local_model]) ++ if(below_floor_otp?, do: [], else: [:below_floor_otp]) ++ + if(below_headless_floor_otp?, do: [], else: [:below_headless_floor_otp]) ++ if(mldsa87_runtime?, do: [], else: [:mldsa87_runtime]) ) diff --git a/test/trinity/toolchain_node_test.exs b/test/trinity/toolchain_node_test.exs index 6b28ce6..161ec56 100644 --- a/test/trinity/toolchain_node_test.exs +++ b/test/trinity/toolchain_node_test.exs @@ -18,6 +18,18 @@ defmodule Trinity.ToolchainNodeTest do The child is the same compiled tree run on the older runtime, which is what a release copied onto an older ERTS would be. Measured before this file was written: code compiled on OTP 28.5.0.6 loads and runs on erts 16.1.1 (OTP 28.1.1), Elixir 1.20.4 and `crypto` included. + + ## The headless release's floor (slice 141) + + The headless release is held to OTP 28.5.0.8 on the OTP 28 line only. Two cases show it: + + * one untagged, on this suite's own runtime, whose expectation comes from that runtime's + `OTP_VERSION` file, read here and not by `Trinity.Toolchain`. The gate's 28.5.0.6 refuses a + headless node; the FIPS image's 28.5.0.8 boots one. Each run prints which it was; + * one tagged `:below_headless_floor_otp`, where `TRINITY_BELOW_HEADLESS_FLOOR_OTP` names the `bin` + of OTP 28.5.0.7, the release before the floor. The refusal names erts and every application + below the row, and the same runtime boots a desktop node, so the two floors are told apart on + one runtime. """ use ExUnit.Case, async: false @@ -120,6 +132,25 @@ defmodule Trinity.ToolchainNodeTest do |> Enum.find("", &String.starts_with?(&1, "BOOT_REFUSED")) end + # The refusal line with the quotes of the reason unescaped: the reason is printed inside the + # inspected RuntimeError message, so its strings arrive as \"...\". + defp refusal(out), do: out |> refusal_line() |> String.replace(~S(\"), ~S(")) + + # The OTP release of the VM running this suite, from the `OTP_VERSION` file a full installation + # carries. An assembled release has none (slice 130, D-130-11), which is why `Trinity.Toolchain` + # reads application versions instead; that makes this an oracle independent of the code under + # test, not a copy of it. + defp this_otp do + major = List.to_string(:erlang.system_info(:otp_release)) + + [:code.root_dir(), "releases", major, "OTP_VERSION"] + |> Path.join() + |> File.read!() + |> String.trim() + end + + defp version_list(v), do: v |> String.split(".") |> Enum.map(&String.to_integer/1) + # The erts version and OTP major of the runtime in `bin`, asked of that runtime itself. defp runtime_of(bin) do {out, 0} = @@ -177,6 +208,122 @@ defmodule Trinity.ToolchainNodeTest do assert line =~ erts, line assert line =~ floor[major], line end + + # Slice 141. The version comparison refuses first at boot, so the probe's own answer on a + # runtime that carries the defect is asked for directly. + test "slice 141: the decoder probe finds the defect itself on that runtime" do + bin = System.fetch_env!("TRINITY_BELOW_FLOOR_OTP") + + {out, 0} = + System.cmd( + "mix", + [ + "run", + "--no-start", + "-e", + ~s|IO.puts("DECODER " <> inspect(Trinity.Toolchain.check_decoder()))| + ], + env: [{"MIX_ENV", "test"}, {"PATH", bin <> ":" <> System.get_env("PATH")}], + stderr_to_stdout: true + ) + + line = out |> String.split("\n") |> Enum.find("", &String.starts_with?(&1, "DECODER")) + assert line == "DECODER {:error, {:decoder_accepts_oversize_tuple, 16777216}}", out + end + end + + describe "slice 141: the decoder is asked at boot" do + # No runtime reports a fixed version over an unfixed decoder, so the seam plants one. A tree + # whose boot skipped the probe ignores the seam and boots, which is this test's red. + test "a node whose decoder accepts the oversize tuple refuses, naming it" do + {root, tag} = case_setup("decoder-planted") + + {out, status} = + boot( + [{"XDG_DATA_HOME", root}, {"TRINITY_BOOT_TAG", tag}], + "Application.put_env(:trinity, :decoder_probe_fault, true)\n" <> @report + ) + + assert status == 3, + "a node with a planted decoder booted:\n" <> String.slice(out, -3000, 3000) + + assert refusal(out) =~ "{:decoder_accepts_oversize_tuple, 16777216}", refusal(out) + end + end + + describe "slice 141: the headless release refuses below OTP 28.5.0.8" do + test "a headless node on this suite's runtime boots only at 28.5.0.8 or later on OTP 28" do + otp = this_otp() + [major | _] = version_list(otp) + at_floor? = major == 28 and version_list(otp) >= [28, 5, 0, 8] + + IO.puts( + "toolchain_node_test: OTP #{otp}, so a headless node " <> + if(at_floor?, do: "boots", else: "refuses") + ) + + for {name, env} <- [ + {"headless-mode", [{"TRINITY_MODE", "headless"}]}, + {"headless-release", [{"RELEASE_NAME", "headless"}]} + ] do + {root, tag} = case_setup(name) + + {out, status} = + boot([{"XDG_DATA_HOME", root}, {"TRINITY_BOOT_TAG", tag} | env], @report) + + if at_floor? do + assert status == 0, "#{name}: " <> String.slice(out, -3000, 3000) + assert out =~ "BOOT_OK" + else + line = refusal(out) + assert status == 3, "#{name} on OTP #{otp} booted:\n" <> String.slice(out, -3000, 3000) + + if major == 28 do + assert line =~ "{:otp_below_floor, 28, \"28.5.0.8\"", line + else + assert line =~ "otp_major_unsupported", line + end + end + end + end + + @tag :below_headless_floor_otp + test "OTP 28.5.0.7 refuses a headless node, naming erts and ssl, and boots a desktop one" do + bin = System.fetch_env!("TRINITY_BELOW_HEADLESS_FLOOR_OTP") + + assert File.exists?(Path.join(bin, "erl")), + "TRINITY_BELOW_HEADLESS_FLOOR_OTP=#{bin} has no erl; a missing runtime is a failure here" + + assert {"28", "16.4.0.6"} = runtime_of(bin) + path = {"PATH", bin <> ":" <> System.get_env("PATH")} + + {root, tag} = case_setup("below-headless-floor") + + {out, status} = + boot( + [ + {"XDG_DATA_HOME", root}, + {"TRINITY_BOOT_TAG", tag}, + {"TRINITY_MODE", "headless"}, + path + ], + @report + ) + + line = refusal(out) + + assert status == 3, + "a headless node on OTP 28.5.0.7 booted:\n" <> String.slice(out, -3000, 3000) + + assert line =~ "{:otp_below_floor, 28, \"28.5.0.8\"", line + assert line =~ ~s|{:ssl, "11.6.0.6", "11.6.0.7"}|, line + assert line =~ ~s|{:erts, "16.4.0.6", "16.4.0.7"}|, line + + {root, tag} = case_setup("below-headless-floor-desktop") + {out, status} = boot([{"XDG_DATA_HOME", root}, {"TRINITY_BOOT_TAG", tag}, path], @report) + assert status == 0, "a desktop node on OTP 28.5.0.7: " <> String.slice(out, -3000, 3000) + assert out =~ "BOOT_OK" + end end describe "AC3: the pinned runtime boots, so the refusal can be met" do diff --git a/test/trinity/toolchain_test.exs b/test/trinity/toolchain_test.exs index 53af670..bba41e9 100644 --- a/test/trinity/toolchain_test.exs +++ b/test/trinity/toolchain_test.exs @@ -3,7 +3,9 @@ defmodule Trinity.ToolchainTest do @moduledoc """ Slice 137, AC1 and AC4: the floor decision, at every boundary, against the rows checked in at - `priv/otp_floor.table`. The boot that applies it is `Trinity.ToolchainNodeTest`. + `priv/otp_floor.table`. Slice 141: the headless release's floor (OTP 28.5.0.8, the OTP 28 line + only), which release a node is, and the decoder probe. The boot that applies them is + `Trinity.ToolchainNodeTest`. """ use ExUnit.Case, async: true use ExUnitProperties @@ -25,6 +27,30 @@ defmodule Trinity.ToolchainTest do 29 => %{release: "29.0.4"} } = Toolchain.floor() + + assert Toolchain.floor() == Toolchain.floor(:desktop) + end + + test "slice 141: the headless release's floor is one row, OTP-28.5.0.8, from the same table" do + assert %{28 => %{release: "28.5.0.8", apps: apps}} = Toolchain.floor(:headless) + assert map_size(Toolchain.floor(:headless)) == 1 + + # erts and the applications whose CVEs 28.5.0.8 fixed, read from its row. The fixes are in + # the applications, and erts-16.4.0.7 can be installed without them, so both are compared. + assert apps.erts == "16.4.0.7" + assert apps.ssl == "11.6.0.7" + assert apps.public_key == "1.20.3.6" + assert apps.inets == "9.6.2.4" + assert apps.ssh == "5.5.2.7" + end + + test "slice 141: every application in the headless row is at or above the desktop row's" do + desktop = Toolchain.floor(:desktop)[28].apps + + for {app, vsn} <- Toolchain.floor(:headless)[28].apps, + required = desktop[app], + required != nil, + do: assert(Toolchain.at_least?(vsn, required), "#{app} #{vsn} < #{required}") end test "each names erts and the applications whose CVEs the release fixed" do @@ -143,6 +169,101 @@ defmodule Trinity.ToolchainTest do end end + describe "slice 141: check_otp/4 for the headless release" do + # The runtimes measured on 2026-10-20 (`erlang:system_info(version)` and `otp_versions.table`). + @r28506 %{ssl: "11.6.0.5", public_key: "1.20.3.4", inets: "9.6.2.3", ssh: "5.5.2.5"} + @r28507 %{ssl: "11.6.0.6", public_key: "1.20.3.5", inets: "9.6.2.3", ssh: "5.5.2.6"} + @r28508 %{ssl: "11.6.0.7", public_key: "1.20.3.6", inets: "9.6.2.4", ssh: "5.5.2.7"} + + test "28.5.0.8 passes; 28.5.0.7 refuses, naming erts and every application below the row" do + assert Toolchain.check_otp(28, "16.4.0.7", @r28508, :headless) == :ok + + assert Toolchain.check_otp(28, "16.4.0.6", @r28507, :headless) == + {:error, + {:otp_below_floor, 28, "28.5.0.8", + [ + {:erts, "16.4.0.6", "16.4.0.7"}, + {:inets, "9.6.2.3", "9.6.2.4"}, + {:public_key, "1.20.3.5", "1.20.3.6"}, + {:ssh, "5.5.2.6", "5.5.2.7"}, + {:ssl, "11.6.0.6", "11.6.0.7"} + ]}} + end + + test "erts at the floor with ssl below it refuses, naming ssl" do + assert Toolchain.check_otp(28, "16.4.0.7", %{@r28508 | ssl: "11.6.0.6"}, :headless) == + {:error, {:otp_below_floor, 28, "28.5.0.8", [{:ssl, "11.6.0.6", "11.6.0.7"}]}} + end + + test "the desktop floor still passes 28.5.0.6, which the headless floor refuses" do + assert Toolchain.check_otp(28, "16.4.0.6", @r28506) == :ok + assert Toolchain.check_otp(28, "16.4.0.6", @r28506, :desktop) == :ok + + assert {:error, {:otp_below_floor, 28, "28.5.0.8", _}} = + Toolchain.check_otp(28, "16.4.0.6", @r28506, :headless) + end + + test "every line but OTP 28 refuses, above it as well as below" do + for {major, erts} <- [{27, "15.2.7.11"}, {29, "17.0.4"}, {29, "17.1"}, {30, "18.0"}], + do: + assert( + Toolchain.check_otp(major, erts, %{}, :headless) == + {:error, {:otp_major_unsupported, major, erts}} + ) + end + end + + describe "slice 141: kind/1, which floor a node is held to" do + test "the headless release, by its release name or by its mode, is :headless" do + assert Toolchain.kind(%{"RELEASE_NAME" => "headless"}) == :headless + assert Toolchain.kind(%{"TRINITY_MODE" => "headless"}) == :headless + + assert Toolchain.kind(%{"RELEASE_NAME" => "desktop", "TRINITY_MODE" => "headless"}) == + :headless + + assert Toolchain.kind(%{"RELEASE_NAME" => "headless", "TRINITY_MODE" => "desktop"}) == + :headless + end + + test "anything else is :desktop" do + assert Toolchain.kind(%{}) == :desktop + assert Toolchain.kind(%{"RELEASE_NAME" => "desktop"}) == :desktop + assert Toolchain.kind(%{"TRINITY_MODE" => "Headless"}) == :desktop + end + end + + describe "slice 141: check_decoder/1, the CVE-2026-55737 behaviour" do + test "this runtime refuses a tuple one element over erts's limit and decodes the control" do + assert Toolchain.check_decoder() == :ok + end + + test "a decoder that builds the oversize tuple is refused, naming its arity" do + # A fixed runtime cannot build a tuple that large at all (`erlang:make_tuple/2` is held to + # the same limit), so the stand-in returns a term: accepting is answering without raising. + accepts = fn _bin -> {:decoded} end + + assert Toolchain.check_decoder(accepts) == + {:error, {:decoder_accepts_oversize_tuple, 16_777_216}} + end + + test "a decoder that refuses everything fails the control, so a refusal is not read as a fix" do + refuses = fn _ -> :erlang.error(:badarg) end + assert Toolchain.check_decoder(refuses) == {:error, {:decoder_control_failed, 256}} + end + + test "a decoder that fails another way is a refusal, not a hang and not a pass" do + crashes = fn _ -> exit(:boom) end + assert Toolchain.check_decoder(crashes) == {:error, {:decoder_probe_failed, :boom}} + end + + test "the probe is the encoding the fix refuses: LARGE_TUPLE_EXT with arity 2^24" do + <<131, 105, arity::32, rest::binary>> = Toolchain.decoder_probe() + assert arity == 16_777_216 + assert byte_size(rest) == arity + assert rest == :binary.copy(<<106>>, arity) + end + end + describe "AC4: check_elixir/1" do test "1.20.x passes; 1.19.5, a 1.20 release candidate and 1.21.0 refuse" do assert Toolchain.check_elixir("1.20.0") == :ok From 2e0918b5571a344d49cd2a4b4de92864ec80a128 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 20 Oct 2026 09:49:31 -0400 Subject: [PATCH 2/2] fix(s141): the FIPS image installs the Hex and rebar3 the hardened image pins, not the newest Signed-off-by: Ayla Croft --- CHANGELOG.md | 4 +++- ci/fips/Containerfile | 18 +++++++++++++++- docs/fips-leg.md | 2 +- test/ci_pins_test.exs | 50 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 test/ci_pins_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 403520a..64d2d1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,7 +98,9 @@ can be installed without them, so the check compares every loaded application wi of erlang/otp's `otp_versions.table`, checked in verbatim. The desktop keeps the slice 137 floor, because its runtime is the one Burrito can fetch; ADR-0014 carries a proposed amendment that says so. Every node now also asks its decoder directly whether it refuses the oversize tuple CVE-2026-55737 is about, instead of trusting the -version alone. The container images build OTP 28.5.0.8. +version alone. The container images build OTP 28.5.0.8, and the FIPS leg's image now installs the +Hex and rebar3 the hardened image pins rather than the newest, which changed under it when it was +rebuilt. Verified by the test suite: the floor at each boundary for both releases; a real headless boot on the suite's own runtime, refused on 28.5.0.6 and booting on 28.5.0.8, with the expectation read from diff --git a/ci/fips/Containerfile b/ci/fips/Containerfile index 58cbe10..c090386 100644 --- a/ci/fips/Containerfile +++ b/ci/fips/Containerfile @@ -60,11 +60,27 @@ RUN set -eu; \ # Hex and rebar in a path that does not depend on HOME: a GitHub container job sets HOME to # /github/home, and an install under /root would be invisible there. +# +# Both pinned, as ci/ironbank/hardening_manifest.yaml pins them, by the SHA-512 builds.hex.pm +# publishes (installs/hex.csv, installs/rebar.csv). This line was `mix local.hex --force && mix +# local.rebar --force`, which installs the newest of each when the image is built: the image built +# on 2026-10-08 got Hex 2.5.1, the one rebuilt for OTP 28.5.0.8 on 2026-10-20 got 2.5.3, and the +# bill of materials names the running Hex, so the gate's THIRD_PARTY_LICENSES.md check failed on +# the new image (slice 141). test/ci_pins_test.exs holds these lines to the manifest. ENV PATH=/usr/local/elixir/bin:/usr/local/bin:$PATH \ LANG=C.UTF-8 \ MIX_HOME=/opt/mix \ HEX_HOME=/opt/hex -RUN mix local.hex --force && mix local.rebar --force +ARG HEX_SHA512=690f12f6933088bb32f1d7125485fb13426318c03cfeabcbd3cce55824cddf2a701104b09b7f4cd7092fe7f76d5b669e391a072428ec4526879b36f62820ae5d +ARG REBAR3_SHA512=992fd755b7926fae455e5e07d9d195f4d3e7f181609eed1b9cabfe548624df10d148cd4b59bda40bebb185d3d68f9a9fd68a70b294101c8ad9cf0fadcc683d24 +RUN set -eu; \ + curl -fsSLo /tmp/hex-2.5.1-otp-28.ez https://builds.hex.pm/installs/1.20.0/hex-2.5.1-otp-28.ez; \ + echo "${HEX_SHA512} /tmp/hex-2.5.1-otp-28.ez" | sha512sum -c -; \ + curl -fsSLo /tmp/rebar3-3.25.1-otp-28 https://builds.hex.pm/installs/1.18.4/rebar3-3.25.1-otp-28; \ + echo "${REBAR3_SHA512} /tmp/rebar3-3.25.1-otp-28" | sha512sum -c -; \ + mix archive.install /tmp/hex-2.5.1-otp-28.ez --force; \ + mix local.rebar rebar3 /tmp/rebar3-3.25.1-otp-28 --force; \ + rm -f /tmp/hex-2.5.1-otp-28.ez /tmp/rebar3-3.25.1-otp-28 # The image proves its own build: the crypto NIF reports the FIPS provider available, and # entering FIPS mode is possible. Which mode a container runs in is the leg's decision, made diff --git a/docs/fips-leg.md b/docs/fips-leg.md index b7232c9..46fae55 100644 --- a/docs/fips-leg.md +++ b/docs/fips-leg.md @@ -12,7 +12,7 @@ name the command that produced them. | Piece | Where | What it does | |---|---|---| -| `ci/fips/Containerfile` | the image | UBI9 by digest; OTP (the version `ci/container.tool-versions` names, 28.5.0.8; ADR-0014) from the release tarball with `--enable-fips --with-ssl=/usr`; Elixir 1.20.4 from the release zip; both archives checked against SHA-256 digests pinned in the file; git, python3 with PyYAML, gcc and procps for the gate's own steps | +| `ci/fips/Containerfile` | the image | UBI9 by digest; OTP (the version `ci/container.tool-versions` names, 28.5.0.8; ADR-0014) from the release tarball with `--enable-fips --with-ssl=/usr`; Elixir 1.20.4 from the release zip; both archives checked against SHA-256 digests pinned in the file; Hex 2.5.1 and rebar3 3.25.1 from the archives `ci/ironbank/hardening_manifest.yaml` declares, checked against its SHA-512s (`test/ci_pins_test.exs`), never the newest at build time; git, python3 with PyYAML, gcc and procps for the gate's own steps | | `.github/workflows/fips-image.yml` | builds the image | On a change to `.tool-versions` (Elixir), `ci/container.tool-versions` (OTP), the Containerfile or the tag script, and on demand; pushes to `ghcr.io/scriptkittyos/trinity-fips` under the tag below; a tag already published is not rebuilt | | `scripts/fips_image_tag.sh` | both workflows | The tag: sixteen hex digits of SHA-256 over `.tool-versions`, `ci/container.tool-versions` and the Containerfile, so the builder and the puller cannot disagree | | `.github/workflows/gate.yml`, jobs `fips-tag` and `fips` | the leg | Pulls the image, fetches and compiles dependencies with the mode off, asserts the mode, checks the mode-off listing, then runs `plan_check`, the DCO check and `mix gate`, all in the mode | diff --git a/test/ci_pins_test.exs b/test/ci_pins_test.exs new file mode 100644 index 0000000..6ee5cc6 --- /dev/null +++ b/test/ci_pins_test.exs @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule CiPinsTest do + @moduledoc """ + Slice 141: the FIPS leg's image installs the Hex and rebar3 the Iron Bank manifest declares, by + the same URL and SHA-512, and never "the newest". + + The image used to run `mix local.hex --force && mix local.rebar --force`. Rebuilt for OTP + 28.5.0.8 it got Hex 2.5.3 where the image of 2026-10-08 had 2.5.1, and the bill of materials names + the running Hex, so the gate's `THIRD_PARTY_LICENSES.md` check failed on the new image. An + unpinned install is invisible until the image is rebuilt, which is why this is a test and not a + comment. + """ + use ExUnit.Case, async: true + + @containerfile "ci/fips/Containerfile" + @manifest "ci/ironbank/hardening_manifest.yaml" + + # `{url, sha512}` of the manifest resource whose filename starts with `prefix`. + defp resource(prefix) do + %{"resources" => resources} = YamlElixir.read_from_file!(@manifest) + + %{"url" => url, "validation" => %{"type" => "sha512", "value" => sha512}} = + Enum.find(resources, &String.starts_with?(&1["filename"], prefix)) + + {url, sha512} + end + + defp arg(text, name) do + [_, value] = Regex.run(~r/^ARG #{name}=(\S+)$/m, text) + value + end + + test "Hex and rebar3 come from the manifest's URLs, checked against its SHA-512s" do + text = File.read!(@containerfile) + + for {prefix, arg_name} <- [{"hex-", "HEX_SHA512"}, {"rebar3-", "REBAR3_SHA512"}] do + {url, sha512} = resource(prefix) + assert text =~ url, "#{@containerfile} does not fetch #{url}" + assert arg(text, arg_name) == sha512, "#{arg_name} is not the manifest's SHA-512 for #{url}" + assert text =~ ~s|echo "${#{arg_name}} /tmp/#{Path.basename(url)}" \| sha512sum -c -| + end + end + + test "nothing installs the newest Hex or rebar" do + text = File.read!(@containerfile) + refute text =~ ~r/^(?!#).*mix local\.hex/m + refute text =~ ~r/^(?!#).*mix local\.rebar\s+--force/m + end +end