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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/fips-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ on:
push:
paths:
- .tool-versions
- ci/container.tool-versions
- ci/fips/Containerfile
- scripts/fips_image_tag.sh
- .github/workflows/fips-image.yml
Expand All @@ -34,11 +35,13 @@ jobs:
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

- name: The tag, and the versions .tool-versions names
# OTP is the container's (ci/container.tool-versions), which may lead the desktop's pin in
# .tool-versions (ADR-0014); Elixir is the one Elixir pin.
- name: The tag, the container's OTP and the Elixir .tool-versions names
id: meta
run: |
echo "ref=$(scripts/fips_image_tag.sh --ref)" >> "$GITHUB_OUTPUT"
echo "otp=$(awk '$1 == "erlang" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT"
echo "otp=$(awk '$1 == "erlang" { print $2 }' ci/container.tool-versions)" >> "$GITHUB_OUTPUT"
echo "elixir=$(awk '$1 == "elixir" { print $2 }' .tool-versions | cut -d- -f1)" >> "$GITHUB_OUTPUT"

- name: Log in to the registry
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,8 @@ jobs:
sleep 30
done
echo "::error::$ref was not published within 30 minutes. It is built by the" \
"fips-image workflow, which runs when ci/fips/Containerfile, .tool-versions or" \
"fips-image workflow, which runs when ci/fips/Containerfile, .tool-versions," \
"ci/container.tool-versions or" \
"scripts/fips_image_tag.sh changes. Check that workflow's run for this commit."
exit 1

Expand Down
149 changes: 149 additions & 0 deletions .github/workflows/headless-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
#
# Slice 130: builds the hardened headless image from ci/ironbank/Dockerfile the way Iron Bank's
# pipeline would (scripts/headless_image.sh stages every input from hardening_manifest.yaml and
# checks its digest; the Dockerfile reaches no network), then checks the built image, never its
# recipe (docs/regulated/headless-image.md):
#
# the submission tree and the published build's base pins mix trinity.ironbank.lint
# the image starts its runtime bin/headless eval, then start
# AC1 and AC6: user and layer contents mix trinity.image.inspect
# AC3 and AC4: vulnerability scan against justifications grype, then mix trinity.image.findings
# AC5: STIG applicability statement scripts/stig_scan.sh, then mix trinity.image.stig
#
# Every check after the build runs and reports even when an earlier one fails, so one red check
# does not hide the others; the job fails if any did. It runs when an input of the image or of the
# checks changes, on demand, and daily, because new advisories appear against an image that has
# not changed (Iron Bank rescans its images on a schedule for the same reason). Nothing is pushed:
# publishing and signing the image is slice 131's.
name: headless-image

on:
push:
paths:
- ci/ironbank/**
- ci/headless/**
- scripts/headless_image.sh
- scripts/stig_scan.sh
- lib/mix/tasks/trinity.ironbank.lint.ex
- lib/mix/tasks/trinity.image.*.ex
- mix.exs
- mix.lock
- config/**
- .tool-versions
- ci/container.tool-versions
- .github/workflows/headless-image.yml
schedule:
- cron: "17 6 * * *"
workflow_dispatch:

permissions:
contents: read

jobs:
image:
runs-on: ubuntu-latest
# Not measured on a runner yet: OTP from source took 40 s and the dependency compile, EXLA's C++
# included, about 155 s on a 32-core machine (slice 130 NOTES). The ceiling is generous until the
# first runs say otherwise.
timeout-minutes: 120
env:
IMAGE: trinity-headless:ci
# grype v0.119.0, pinned by digest: a scanner is code that reads the image and runs in CI, and
# a tag can be moved (slice 130 NOTES, D-130-6).
GRYPE: anchore/grype:v0.119.0@sha256:8c2c9234a345577a6d321a4753aa3ee1276d8975c8452d2344a56b57733ecad3
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

- uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1
with:
version-file: .tool-versions
version-type: strict

- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
deps
_build
key: ${{ runner.os }}-mix-image-${{ hashFiles('mix.lock') }}
restore-keys: ${{ runner.os }}-mix-image-

- name: Compile the checks
run: |
mix deps.get
mix compile

- name: The submission tree and the published build's base pins
run: mix trinity.ironbank.lint

- name: Build the image from staged, digest-checked resources
id: build
run: |
scripts/headless_image.sh build "$IMAGE"
docker image inspect "$IMAGE" --format 'id={{.Id}} size={{.Size}} user={{.Config.User}}'
echo "digest=$(docker image inspect "$IMAGE" --format '{{.Id}}')" >> "$GITHUB_OUTPUT"

# `bin/headless version` exits 0 without starting the VM (slice 130 NOTES, measurement 1), so
# this starts it: eval runs the runtime, and start must answer on its one port with a 401 for a
# request without the bearer.
- name: The image starts its runtime, read-only
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
run: |
docker run --rm --read-only --tmpfs /tmp "$IMAGE" /opt/trinity/bin/headless eval \
'IO.puts("otp=#{System.otp_release()} fips_provider=#{inspect(:crypto.info()[:fips_provider_available])} node=#{inspect(Node.self())}")'
cid=$(docker run -d --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges \
-p 127.0.0.1:4000:4000 -e TRINITY_MCP_SERVER_TOKEN=ci-token "$IMAGE")
for _ in $(seq 1 60); do
curl -s -o /dev/null http://127.0.0.1:4000/ && break
sleep 1
done
status=$(curl -s -o /dev/null -w '%{http_code}' -X POST http://127.0.0.1:4000/mcp \
-H 'content-type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"ping"}')
docker logs "$cid" | tail -20
docker rm -f "$cid" >/dev/null
echo "POST /mcp without a bearer: $status"
test "$status" = 401

- name: "AC1 and AC6: the image's user and its layers"
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
run: mix trinity.image.inspect "$IMAGE"

- name: "AC3: scan the image, with no network at scan time"
id: scan
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
run: |
mkdir -p out/grype-db out/tmp
docker save "$IMAGE" -o out/image.tar
# TMPDIR on both runs: as a non-root user grype cannot write the image's /tmp, and the
# update failed on its listing file without it (run 37764956008).
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD/out:/s" \
-e TMPDIR=/s/tmp -e GRYPE_DB_CACHE_DIR=/s/grype-db "$GRYPE" db update
docker run --rm --network none --user "$(id -u):$(id -g)" -v "$PWD/out:/s" \
-e TMPDIR=/s/tmp -e GRYPE_DB_CACHE_DIR=/s/grype-db \
-e GRYPE_DB_AUTO_UPDATE=false -e GRYPE_CHECK_FOR_APP_UPDATE=false \
"$GRYPE" docker-archive:/s/image.tar -o json --file /s/grype.json
rm -f out/image.tar

- name: "AC3 and AC4: findings against ci/headless/justifications.yaml"
if: ${{ !cancelled() && steps.scan.outcome == 'success' }}
run: mix trinity.image.findings --scan out/grype.json --out out/findings.csv

- name: "AC5: the STIG applicability statement"
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
run: |
scripts/stig_scan.sh "$IMAGE" out/stig
mix trinity.image.stig --results out/stig/stig-results.xml --out out/stig/stig-applicability.md \
--image "$IMAGE" --digest "${{ steps.build.outputs.digest }}"

- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: ${{ !cancelled() }}
with:
name: headless-image-evidence
path: |
out/grype.json
out/findings.csv
out/stig/stig-results.xml
out/stig/stig-report.html
out/stig/stig-applicability.md
if-no-files-found: warn
124 changes: 124 additions & 0 deletions .github/workflows/otp-canary.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
#
# The OTP canary (owner decision D2, ADR-0014). The desktop pin in .tool-versions waits for
# Burrito's ERTS builds; the container pin in ci/container.tool-versions does not. Every day this
# asks whether Burrito can now fetch the container's OTP for all three desktop targets, and the
# first day it can, it opens a pull request that moves the desktop pin there. Merging that pull
# request is how the desktop gets the CVE-2026-89422 fix, and with it `Trinity.TLS` stops holding
# the desktop's TLS clients to TLS 1.2 (it decides from the running runtime, not from a flag).
#
# Two things a reader of the pull request needs to know:
# * a pull request opened with GITHUB_TOKEN starts no workflow, so its gate runs when a person
# pushes to the branch (or closes and reopens it);
# * the repository setting "Allow GitHub Actions to create and approve pull requests" must be on,
# or the last step fails with a permissions error that names it.
# It never opens a second pull request for the same version: a closed one is a person's answer.
name: otp-canary

on:
schedule:
- cron: "41 7 * * *"
workflow_dispatch:

permissions:
contents: read

jobs:
probe:
runs-on: ubuntu-latest
outputs:
result: ${{ steps.probe.outputs.result }}
version: ${{ steps.probe.outputs.version }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

- name: Can Burrito fetch the container's OTP for every desktop target?
id: probe
run: scripts/otp_canary.sh probe | tee -a "$GITHUB_OUTPUT"

flip:
needs: probe
if: needs.probe.outputs.result == 'ready'
runs-on: ubuntu-latest
# The branch push and the pull request, and nothing else in this workflow.
permissions:
contents: write
pull-requests: write
env:
VERSION: ${{ needs.probe.outputs.version }}
BRANCH: canary/otp-${{ needs.probe.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

- name: Has a pull request for this version been opened before?
id: seen
run: |
count=$(gh pr list --head "$BRANCH" --state all --json number --jq length)
if [ "$count" != 0 ] || git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null; then
echo "::notice::$BRANCH already has a pull request or a branch; nothing to do"
echo "seen=true" >> "$GITHUB_OUTPUT"
else
echo "seen=false" >> "$GITHUB_OUTPUT"
fi

# The flip first, so setup-beam installs the OTP the pull request moves to: the licence list
# names the OTP applications of the runtime that generates it, and VERSIONS.md is generated
# from the pin list the flip edits. The gate checks both.
- name: Move the desktop pin
if: steps.seen.outputs.seen == 'false'
run: scripts/otp_canary.sh flip

- uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1
if: steps.seen.outputs.seen == 'false'
with:
version-file: .tool-versions
version-type: strict

- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
if: steps.seen.outputs.seen == 'false'
with:
path: |
deps
_build
key: ${{ runner.os }}-mix-canary-${{ hashFiles('mix.lock') }}
restore-keys: ${{ runner.os }}-mix-

- name: Regenerate VERSIONS.md and THIRD_PARTY_LICENSES.md on the new OTP
if: steps.seen.outputs.seen == 'false'
env:
MIX_ENV: test
run: |
mix deps.get
mix versions.gen
mix trinity.sbom
mix trinity.third_party_licenses --write

- name: Open the pull request
if: steps.seen.outputs.seen == 'false'
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git switch -c "$BRANCH"
git commit -s -a -m "build(otp): move the desktop pin to OTP $VERSION (ADR-0014)"
git push origin "$BRANCH"
cat > "$RUNNER_TEMP/body.md" <<EOF
Burrito can now fetch OTP $VERSION for every desktop target (the probe in this run's
log), so the desktop pin in \`.tool-versions\` moves to the container's pin.

What this changes: the desktop runs an OTP with the CVE-2026-89422 fix, and
\`Trinity.TLS\` stops holding its TLS clients to TLS 1.2 on its own.

Before merging, by hand:
- push a commit to this branch so the gate runs (a pull request opened by a workflow
starts none);
- update the Erlang/OTP row's note in \`lib/trinity/versions.ex\`, then \`mix versions.gen\`
(the pin itself, \`VERSIONS.md\` and \`THIRD_PARTY_LICENSES.md\` are already moved);
- update the OTP lines in \`README.md\` and \`docs/packaging.md\`;
- append the closing of the window to \`docs/adr/0014-otp-pins-split.md\`;
- check the \`package\` workflow on all three targets.
EOF
gh pr create --base main --head "$BRANCH" \
--title "build(otp): move the desktop pin to OTP $VERSION" \
--body-file "$RUNNER_TEMP/body.md"
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
erlang 28.5.0.5
erlang 28.5.0.6
elixir 1.20.4-otp-28
zig 0.16.0
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,35 @@ evidence for each increment is retained by the maintainers and summarised here.

## 2026-10-08

### `slice/130`: the headless image, hardened, and checked as built

The headless container is what a government program would actually run, and it was built to run
rather than to be assessed: Debian, a package manager and `curl` in the final image, and an Erlang
distribution listener on every interface whose cookie was baked into the image for anyone who
pulled it. It is now built on UBI9 micro from one recipe, `ci/ironbank/Dockerfile`, which is laid
out as an Iron Bank submission and reaches no network: every input arrives as a file with its
digest. The image this project publishes is the same file built with its bases pinned by digest.

What is claimed about the image is checked against the built image, never against its recipe. It
runs as UID 10001 with a read-only root filesystem and distribution off. Its layers carry no
package manager, compiler, shell history, documentation, SUID file or key material beyond what is
allowed by name with a reason. A vulnerability scan fails on any High or Critical finding without a
justification, and on a justification for something no longer found. Its STIG applicability
statement is generated from OpenSCAP's evaluation of it, with every rule of the profile given a
disposition. The scanner was chosen by measurement: of the two tried, only grype sees the Erlang
runtime inside a release. The image is not in Iron Bank and this is not a compliance determination;
`docs/regulated/headless-image.md` says what it is, how to check it, and what is still open.

The image builds Erlang/OTP 28.5.0.7, which fixes CVE-2026-89422 (OTP's TLS 1.3 client could skip
the server's certificate) and the other OTP advisories the scan reported; the scan now finds none in
the runtime. The desktop cannot have that version until Burrito publishes builds of it, so the
container and the desktop now have separate OTP pins (ADR-0014). The desktop moves to 28.5.0.6, and
while it runs an affected OTP every outbound TLS connection Trinity makes is held to TLS 1.2, the
advisory's own workaround. A test reads every TLS client the release contains from its compiled code
and checks what each one offers to a server on the wire, so a dependency that would open TLS 1.3
fails the gate. A daily job opens the pull request that moves the desktop pin once the builds exist,
and the clamp lifts itself when it does.

### `slice/100`: Trinity as a desktop application

The Tauri window now does what a desktop application is expected to do. A tray icon shows whether
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,10 @@ demonstrate, each tied to the slice that proves it.

## Running from source

Requires the pinned toolchain in `.tool-versions` (Erlang 28.5.0.5, Elixir 1.20.4, Zig 0.16.0),
installed with `asdf install`. Rust 1.92.0 is pinned separately in `rust-toolchain.toml` and is
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
only needed for the desktop shell.

```
Expand Down
Loading
Loading