Skip to content

Commit ea3fa35

Browse files
authored
Merge pull request #117 from ScriptKittyOS/slice/130-image-hardening-iron-bank
feat(s130): the hardened headless image and the evidence about it
2 parents 00071ad + ddf2b93 commit ea3fa35

59 files changed

Lines changed: 6520 additions & 100 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/fips-image.yml‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ on:
1212
push:
1313
paths:
1414
- .tool-versions
15+
- ci/container.tool-versions
1516
- ci/fips/Containerfile
1617
- scripts/fips_image_tag.sh
1718
- .github/workflows/fips-image.yml
@@ -34,11 +35,13 @@ jobs:
3435
steps:
3536
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
3637

37-
- name: The tag, and the versions .tool-versions names
38+
# OTP is the container's (ci/container.tool-versions), which may lead the desktop's pin in
39+
# .tool-versions (ADR-0014); Elixir is the one Elixir pin.
40+
- name: The tag, the container's OTP and the Elixir .tool-versions names
3841
id: meta
3942
run: |
4043
echo "ref=$(scripts/fips_image_tag.sh --ref)" >> "$GITHUB_OUTPUT"
41-
echo "otp=$(awk '$1 == "erlang" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT"
44+
echo "otp=$(awk '$1 == "erlang" { print $2 }' ci/container.tool-versions)" >> "$GITHUB_OUTPUT"
4245
echo "elixir=$(awk '$1 == "elixir" { print $2 }' .tool-versions | cut -d- -f1)" >> "$GITHUB_OUTPUT"
4346
4447
- name: Log in to the registry

‎.github/workflows/gate.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,8 @@ jobs:
212212
sleep 30
213213
done
214214
echo "::error::$ref was not published within 30 minutes. It is built by the" \
215-
"fips-image workflow, which runs when ci/fips/Containerfile, .tool-versions or" \
215+
"fips-image workflow, which runs when ci/fips/Containerfile, .tool-versions," \
216+
"ci/container.tool-versions or" \
216217
"scripts/fips_image_tag.sh changes. Check that workflow's run for this commit."
217218
exit 1
218219
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Slice 130: builds the hardened headless image from ci/ironbank/Dockerfile the way Iron Bank's
5+
# pipeline would (scripts/headless_image.sh stages every input from hardening_manifest.yaml and
6+
# checks its digest; the Dockerfile reaches no network), then checks the built image, never its
7+
# recipe (docs/regulated/headless-image.md):
8+
#
9+
# the submission tree and the published build's base pins mix trinity.ironbank.lint
10+
# the image starts its runtime bin/headless eval, then start
11+
# AC1 and AC6: user and layer contents mix trinity.image.inspect
12+
# AC3 and AC4: vulnerability scan against justifications grype, then mix trinity.image.findings
13+
# AC5: STIG applicability statement scripts/stig_scan.sh, then mix trinity.image.stig
14+
#
15+
# Every check after the build runs and reports even when an earlier one fails, so one red check
16+
# does not hide the others; the job fails if any did. It runs when an input of the image or of the
17+
# checks changes, on demand, and daily, because new advisories appear against an image that has
18+
# not changed (Iron Bank rescans its images on a schedule for the same reason). Nothing is pushed:
19+
# publishing and signing the image is slice 131's.
20+
name: headless-image
21+
22+
on:
23+
push:
24+
paths:
25+
- ci/ironbank/**
26+
- ci/headless/**
27+
- scripts/headless_image.sh
28+
- scripts/stig_scan.sh
29+
- lib/mix/tasks/trinity.ironbank.lint.ex
30+
- lib/mix/tasks/trinity.image.*.ex
31+
- mix.exs
32+
- mix.lock
33+
- config/**
34+
- .tool-versions
35+
- ci/container.tool-versions
36+
- .github/workflows/headless-image.yml
37+
schedule:
38+
- cron: "17 6 * * *"
39+
workflow_dispatch:
40+
41+
permissions:
42+
contents: read
43+
44+
jobs:
45+
image:
46+
runs-on: ubuntu-latest
47+
# Not measured on a runner yet: OTP from source took 40 s and the dependency compile, EXLA's C++
48+
# included, about 155 s on a 32-core machine (slice 130 NOTES). The ceiling is generous until the
49+
# first runs say otherwise.
50+
timeout-minutes: 120
51+
env:
52+
IMAGE: trinity-headless:ci
53+
# grype v0.119.0, pinned by digest: a scanner is code that reads the image and runs in CI, and
54+
# a tag can be moved (slice 130 NOTES, D-130-6).
55+
GRYPE: anchore/grype:v0.119.0@sha256:8c2c9234a345577a6d321a4753aa3ee1276d8975c8452d2344a56b57733ecad3
56+
steps:
57+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
58+
59+
- uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1
60+
with:
61+
version-file: .tool-versions
62+
version-type: strict
63+
64+
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
65+
with:
66+
path: |
67+
deps
68+
_build
69+
key: ${{ runner.os }}-mix-image-${{ hashFiles('mix.lock') }}
70+
restore-keys: ${{ runner.os }}-mix-image-
71+
72+
- name: Compile the checks
73+
run: |
74+
mix deps.get
75+
mix compile
76+
77+
- name: The submission tree and the published build's base pins
78+
run: mix trinity.ironbank.lint
79+
80+
- name: Build the image from staged, digest-checked resources
81+
id: build
82+
run: |
83+
scripts/headless_image.sh build "$IMAGE"
84+
docker image inspect "$IMAGE" --format 'id={{.Id}} size={{.Size}} user={{.Config.User}}'
85+
echo "digest=$(docker image inspect "$IMAGE" --format '{{.Id}}')" >> "$GITHUB_OUTPUT"
86+
87+
# `bin/headless version` exits 0 without starting the VM (slice 130 NOTES, measurement 1), so
88+
# this starts it: eval runs the runtime, and start must answer on its one port with a 401 for a
89+
# request without the bearer.
90+
- name: The image starts its runtime, read-only
91+
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
92+
run: |
93+
docker run --rm --read-only --tmpfs /tmp "$IMAGE" /opt/trinity/bin/headless eval \
94+
'IO.puts("otp=#{System.otp_release()} fips_provider=#{inspect(:crypto.info()[:fips_provider_available])} node=#{inspect(Node.self())}")'
95+
cid=$(docker run -d --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges \
96+
-p 127.0.0.1:4000:4000 -e TRINITY_MCP_SERVER_TOKEN=ci-token "$IMAGE")
97+
for _ in $(seq 1 60); do
98+
curl -s -o /dev/null http://127.0.0.1:4000/ && break
99+
sleep 1
100+
done
101+
status=$(curl -s -o /dev/null -w '%{http_code}' -X POST http://127.0.0.1:4000/mcp \
102+
-H 'content-type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"ping"}')
103+
docker logs "$cid" | tail -20
104+
docker rm -f "$cid" >/dev/null
105+
echo "POST /mcp without a bearer: $status"
106+
test "$status" = 401
107+
108+
- name: "AC1 and AC6: the image's user and its layers"
109+
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
110+
run: mix trinity.image.inspect "$IMAGE"
111+
112+
- name: "AC3: scan the image, with no network at scan time"
113+
id: scan
114+
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
115+
run: |
116+
mkdir -p out/grype-db out/tmp
117+
docker save "$IMAGE" -o out/image.tar
118+
# TMPDIR on both runs: as a non-root user grype cannot write the image's /tmp, and the
119+
# update failed on its listing file without it (run 37764956008).
120+
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD/out:/s" \
121+
-e TMPDIR=/s/tmp -e GRYPE_DB_CACHE_DIR=/s/grype-db "$GRYPE" db update
122+
docker run --rm --network none --user "$(id -u):$(id -g)" -v "$PWD/out:/s" \
123+
-e TMPDIR=/s/tmp -e GRYPE_DB_CACHE_DIR=/s/grype-db \
124+
-e GRYPE_DB_AUTO_UPDATE=false -e GRYPE_CHECK_FOR_APP_UPDATE=false \
125+
"$GRYPE" docker-archive:/s/image.tar -o json --file /s/grype.json
126+
rm -f out/image.tar
127+
128+
- name: "AC3 and AC4: findings against ci/headless/justifications.yaml"
129+
if: ${{ !cancelled() && steps.scan.outcome == 'success' }}
130+
run: mix trinity.image.findings --scan out/grype.json --out out/findings.csv
131+
132+
- name: "AC5: the STIG applicability statement"
133+
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
134+
run: |
135+
scripts/stig_scan.sh "$IMAGE" out/stig
136+
mix trinity.image.stig --results out/stig/stig-results.xml --out out/stig/stig-applicability.md \
137+
--image "$IMAGE" --digest "${{ steps.build.outputs.digest }}"
138+
139+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
140+
if: ${{ !cancelled() }}
141+
with:
142+
name: headless-image-evidence
143+
path: |
144+
out/grype.json
145+
out/findings.csv
146+
out/stig/stig-results.xml
147+
out/stig/stig-report.html
148+
out/stig/stig-applicability.md
149+
if-no-files-found: warn

‎.github/workflows/otp-canary.yml‎

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# The OTP canary (owner decision D2, ADR-0014). The desktop pin in .tool-versions waits for
5+
# Burrito's ERTS builds; the container pin in ci/container.tool-versions does not. Every day this
6+
# asks whether Burrito can now fetch the container's OTP for all three desktop targets, and the
7+
# first day it can, it opens a pull request that moves the desktop pin there. Merging that pull
8+
# request is how the desktop gets the CVE-2026-89422 fix, and with it `Trinity.TLS` stops holding
9+
# the desktop's TLS clients to TLS 1.2 (it decides from the running runtime, not from a flag).
10+
#
11+
# Two things a reader of the pull request needs to know:
12+
# * a pull request opened with GITHUB_TOKEN starts no workflow, so its gate runs when a person
13+
# pushes to the branch (or closes and reopens it);
14+
# * the repository setting "Allow GitHub Actions to create and approve pull requests" must be on,
15+
# or the last step fails with a permissions error that names it.
16+
# It never opens a second pull request for the same version: a closed one is a person's answer.
17+
name: otp-canary
18+
19+
on:
20+
schedule:
21+
- cron: "41 7 * * *"
22+
workflow_dispatch:
23+
24+
permissions:
25+
contents: read
26+
27+
jobs:
28+
probe:
29+
runs-on: ubuntu-latest
30+
outputs:
31+
result: ${{ steps.probe.outputs.result }}
32+
version: ${{ steps.probe.outputs.version }}
33+
steps:
34+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
35+
36+
- name: Can Burrito fetch the container's OTP for every desktop target?
37+
id: probe
38+
run: scripts/otp_canary.sh probe | tee -a "$GITHUB_OUTPUT"
39+
40+
flip:
41+
needs: probe
42+
if: needs.probe.outputs.result == 'ready'
43+
runs-on: ubuntu-latest
44+
# The branch push and the pull request, and nothing else in this workflow.
45+
permissions:
46+
contents: write
47+
pull-requests: write
48+
env:
49+
VERSION: ${{ needs.probe.outputs.version }}
50+
BRANCH: canary/otp-${{ needs.probe.outputs.version }}
51+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
52+
steps:
53+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
54+
55+
- name: Has a pull request for this version been opened before?
56+
id: seen
57+
run: |
58+
count=$(gh pr list --head "$BRANCH" --state all --json number --jq length)
59+
if [ "$count" != 0 ] || git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null; then
60+
echo "::notice::$BRANCH already has a pull request or a branch; nothing to do"
61+
echo "seen=true" >> "$GITHUB_OUTPUT"
62+
else
63+
echo "seen=false" >> "$GITHUB_OUTPUT"
64+
fi
65+
66+
# The flip first, so setup-beam installs the OTP the pull request moves to: the licence list
67+
# names the OTP applications of the runtime that generates it, and VERSIONS.md is generated
68+
# from the pin list the flip edits. The gate checks both.
69+
- name: Move the desktop pin
70+
if: steps.seen.outputs.seen == 'false'
71+
run: scripts/otp_canary.sh flip
72+
73+
- uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1
74+
if: steps.seen.outputs.seen == 'false'
75+
with:
76+
version-file: .tool-versions
77+
version-type: strict
78+
79+
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
80+
if: steps.seen.outputs.seen == 'false'
81+
with:
82+
path: |
83+
deps
84+
_build
85+
key: ${{ runner.os }}-mix-canary-${{ hashFiles('mix.lock') }}
86+
restore-keys: ${{ runner.os }}-mix-
87+
88+
- name: Regenerate VERSIONS.md and THIRD_PARTY_LICENSES.md on the new OTP
89+
if: steps.seen.outputs.seen == 'false'
90+
env:
91+
MIX_ENV: test
92+
run: |
93+
mix deps.get
94+
mix versions.gen
95+
mix trinity.sbom
96+
mix trinity.third_party_licenses --write
97+
98+
- name: Open the pull request
99+
if: steps.seen.outputs.seen == 'false'
100+
run: |
101+
git config user.name "github-actions[bot]"
102+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
103+
git switch -c "$BRANCH"
104+
git commit -s -a -m "build(otp): move the desktop pin to OTP $VERSION (ADR-0014)"
105+
git push origin "$BRANCH"
106+
cat > "$RUNNER_TEMP/body.md" <<EOF
107+
Burrito can now fetch OTP $VERSION for every desktop target (the probe in this run's
108+
log), so the desktop pin in \`.tool-versions\` moves to the container's pin.
109+
110+
What this changes: the desktop runs an OTP with the CVE-2026-89422 fix, and
111+
\`Trinity.TLS\` stops holding its TLS clients to TLS 1.2 on its own.
112+
113+
Before merging, by hand:
114+
- push a commit to this branch so the gate runs (a pull request opened by a workflow
115+
starts none);
116+
- update the Erlang/OTP row's note in \`lib/trinity/versions.ex\`, then \`mix versions.gen\`
117+
(the pin itself, \`VERSIONS.md\` and \`THIRD_PARTY_LICENSES.md\` are already moved);
118+
- update the OTP lines in \`README.md\` and \`docs/packaging.md\`;
119+
- append the closing of the window to \`docs/adr/0014-otp-pins-split.md\`;
120+
- check the \`package\` workflow on all three targets.
121+
EOF
122+
gh pr create --base main --head "$BRANCH" \
123+
--title "build(otp): move the desktop pin to OTP $VERSION" \
124+
--body-file "$RUNNER_TEMP/body.md"

‎.tool-versions‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
erlang 28.5.0.5
1+
erlang 28.5.0.6
22
elixir 1.20.4-otp-28
33
zig 0.16.0

‎CHANGELOG.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,35 @@ evidence for each increment is retained by the maintainers and summarised here.
2424

2525
## 2026-10-08
2626

27+
### `slice/130`: the headless image, hardened, and checked as built
28+
29+
The headless container is what a government program would actually run, and it was built to run
30+
rather than to be assessed: Debian, a package manager and `curl` in the final image, and an Erlang
31+
distribution listener on every interface whose cookie was baked into the image for anyone who
32+
pulled it. It is now built on UBI9 micro from one recipe, `ci/ironbank/Dockerfile`, which is laid
33+
out as an Iron Bank submission and reaches no network: every input arrives as a file with its
34+
digest. The image this project publishes is the same file built with its bases pinned by digest.
35+
36+
What is claimed about the image is checked against the built image, never against its recipe. It
37+
runs as UID 10001 with a read-only root filesystem and distribution off. Its layers carry no
38+
package manager, compiler, shell history, documentation, SUID file or key material beyond what is
39+
allowed by name with a reason. A vulnerability scan fails on any High or Critical finding without a
40+
justification, and on a justification for something no longer found. Its STIG applicability
41+
statement is generated from OpenSCAP's evaluation of it, with every rule of the profile given a
42+
disposition. The scanner was chosen by measurement: of the two tried, only grype sees the Erlang
43+
runtime inside a release. The image is not in Iron Bank and this is not a compliance determination;
44+
`docs/regulated/headless-image.md` says what it is, how to check it, and what is still open.
45+
46+
The image builds Erlang/OTP 28.5.0.7, which fixes CVE-2026-89422 (OTP's TLS 1.3 client could skip
47+
the server's certificate) and the other OTP advisories the scan reported; the scan now finds none in
48+
the runtime. The desktop cannot have that version until Burrito publishes builds of it, so the
49+
container and the desktop now have separate OTP pins (ADR-0014). The desktop moves to 28.5.0.6, and
50+
while it runs an affected OTP every outbound TLS connection Trinity makes is held to TLS 1.2, the
51+
advisory's own workaround. A test reads every TLS client the release contains from its compiled code
52+
and checks what each one offers to a server on the wire, so a dependency that would open TLS 1.3
53+
fails the gate. A daily job opens the pull request that moves the desktop pin once the builds exist,
54+
and the clamp lifts itself when it does.
55+
2756
### `slice/100`: Trinity as a desktop application
2857

2958
The Tauri window now does what a desktop application is expected to do. A tray icon shows whether

‎README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,10 @@ demonstrate, each tied to the slice that proves it.
199199

200200
## Running from source
201201

202-
Requires the pinned toolchain in `.tool-versions` (Erlang 28.5.0.5, Elixir 1.20.4, Zig 0.16.0),
203-
installed with `asdf install`. Rust 1.92.0 is pinned separately in `rust-toolchain.toml` and is
202+
Requires the pinned toolchain in `.tool-versions` (Erlang 28.5.0.6, Elixir 1.20.4, Zig 0.16.0),
203+
installed with `asdf install`. The container images build Erlang 28.5.0.7 from source
204+
(`ci/container.tool-versions`); the desktop's pin waits for Burrito's builds of it and, until then,
205+
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
204206
only needed for the desktop shell.
205207

206208
```

0 commit comments

Comments
 (0)