Skip to content

Commit bf854ec

Browse files
committed
feat(s130): the hardened headless image and the evidence about it
The hardening half of slice 130. Release composition waits on slice 133. One recipe, two artifacts. ci/ironbank/ is laid out as the repo1 project root (Dockerfile, hardening_manifest.yaml, LICENSE, README.md, scripts/entrypoint.sh): OTP built from source with FIPS on UBI9, the release copied onto UBI9 micro, the base through ARG BASE_*, every input a manifest resource with its digest and no network except dnf, UID 10001, Erlang distribution off and no baked cookie. ci/headless/ keeps what belongs to our published image: digest pins, justifications, allowances, STIG dispositions and the submission holds. The Debian Containerfile from slice 061 is removed. Four tasks carry the checks: - trinity.ironbank.lint covers AC2, AC2a, AC6 and AC6a on the tree. - trinity.image.inspect covers AC1 and AC6 on the built image. - trinity.image.findings covers AC3 and AC4, keyed the way Iron Bank's tracker keys a finding. - trinity.image.stig covers AC5 from OpenSCAP's own results. The scanner is grype, chosen by measurement over Trivy: only grype found OTP inside the release. Red evidence R1 to R11 is in the slice NOTES with the logs in proof/. The live image's scan is red on 14 OTP findings, fixed in 28.5.0.7; the toolchain bump is the owner's to approve, so they stay unjustified. Signed-off-by: Ayla Croft <aylacroft@proton.me>
1 parent 3d118a0 commit bf854ec

31 files changed

Lines changed: 4988 additions & 55 deletions
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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+
- .github/workflows/headless-image.yml
36+
schedule:
37+
- cron: "17 6 * * *"
38+
workflow_dispatch:
39+
40+
permissions:
41+
contents: read
42+
43+
jobs:
44+
image:
45+
runs-on: ubuntu-latest
46+
# Not measured on a runner yet: OTP from source took 40 s and the dependency compile, EXLA's C++
47+
# included, about 155 s on a 32-core machine (slice 130 NOTES). The ceiling is generous until the
48+
# first runs say otherwise.
49+
timeout-minutes: 120
50+
env:
51+
IMAGE: trinity-headless:ci
52+
# grype v0.119.0, pinned by digest: a scanner is code that reads the image and runs in CI, and
53+
# a tag can be moved (slice 130 NOTES, D-130-6).
54+
GRYPE: anchore/grype:v0.119.0@sha256:8c2c9234a345577a6d321a4753aa3ee1276d8975c8452d2344a56b57733ecad3
55+
steps:
56+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
57+
58+
- uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1
59+
with:
60+
version-file: .tool-versions
61+
version-type: strict
62+
63+
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
64+
with:
65+
path: |
66+
deps
67+
_build
68+
key: ${{ runner.os }}-mix-image-${{ hashFiles('mix.lock') }}
69+
restore-keys: ${{ runner.os }}-mix-image-
70+
71+
- name: Compile the checks
72+
run: |
73+
mix deps.get
74+
mix compile
75+
76+
- name: The submission tree and the published build's base pins
77+
run: mix trinity.ironbank.lint
78+
79+
- name: Build the image from staged, digest-checked resources
80+
id: build
81+
run: |
82+
scripts/headless_image.sh build "$IMAGE"
83+
docker image inspect "$IMAGE" --format 'id={{.Id}} size={{.Size}} user={{.Config.User}}'
84+
echo "digest=$(docker image inspect "$IMAGE" --format '{{.Id}}')" >> "$GITHUB_OUTPUT"
85+
86+
# `bin/headless version` exits 0 without starting the VM (slice 130 NOTES, measurement 1), so
87+
# this starts it: eval runs the runtime, and start must answer on its one port with a 401 for a
88+
# request without the bearer.
89+
- name: The image starts its runtime, read-only
90+
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
91+
run: |
92+
docker run --rm --read-only --tmpfs /tmp "$IMAGE" /opt/trinity/bin/headless eval \
93+
'IO.puts("otp=#{System.otp_release()} fips_provider=#{inspect(:crypto.info()[:fips_provider_available])} node=#{inspect(Node.self())}")'
94+
cid=$(docker run -d --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges \
95+
-p 127.0.0.1:4000:4000 -e TRINITY_MCP_SERVER_TOKEN=ci-token "$IMAGE")
96+
for _ in $(seq 1 60); do
97+
curl -s -o /dev/null http://127.0.0.1:4000/ && break
98+
sleep 1
99+
done
100+
status=$(curl -s -o /dev/null -w '%{http_code}' -X POST http://127.0.0.1:4000/mcp \
101+
-H 'content-type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"ping"}')
102+
docker logs "$cid" | tail -20
103+
docker rm -f "$cid" >/dev/null
104+
echo "POST /mcp without a bearer: $status"
105+
test "$status" = 401
106+
107+
- name: "AC1 and AC6: the image's user and its layers"
108+
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
109+
run: mix trinity.image.inspect "$IMAGE"
110+
111+
- name: "AC3: scan the image, with no network at scan time"
112+
id: scan
113+
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
114+
run: |
115+
mkdir -p out/grype-db out/tmp
116+
docker save "$IMAGE" -o out/image.tar
117+
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD/out:/s" -e GRYPE_DB_CACHE_DIR=/s/grype-db "$GRYPE" db update
118+
docker run --rm --network none --user "$(id -u):$(id -g)" -v "$PWD/out:/s" \
119+
-e TMPDIR=/s/tmp -e GRYPE_DB_CACHE_DIR=/s/grype-db \
120+
-e GRYPE_DB_AUTO_UPDATE=false -e GRYPE_CHECK_FOR_APP_UPDATE=false \
121+
"$GRYPE" docker-archive:/s/image.tar -o json --file /s/grype.json
122+
rm -f out/image.tar
123+
124+
- name: "AC3 and AC4: findings against ci/headless/justifications.yaml"
125+
if: ${{ !cancelled() && steps.scan.outcome == 'success' }}
126+
run: mix trinity.image.findings --scan out/grype.json --out out/findings.csv
127+
128+
- name: "AC5: the STIG applicability statement"
129+
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
130+
run: |
131+
scripts/stig_scan.sh "$IMAGE" out/stig
132+
mix trinity.image.stig --results out/stig/stig-results.xml --out out/stig/stig-applicability.md \
133+
--image "$IMAGE" --digest "${{ steps.build.outputs.digest }}"
134+
135+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
136+
if: ${{ !cancelled() }}
137+
with:
138+
name: headless-image-evidence
139+
path: |
140+
out/grype.json
141+
out/findings.csv
142+
out/stig/stig-results.xml
143+
out/stig/stig-report.html
144+
out/stig/stig-applicability.md
145+
if-no-files-found: warn

‎CHANGELOG.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,27 @@ evidence for each increment is retained by the maintainers and summarised here.
2222
tree, engineering rules consolidated into `docs/03-conventions.md`, and this changelog added as
2323
the public record of delivered work.
2424

25+
## 2026-10-08
26+
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+
2546
## 2026-09-26
2647

2748
### `slice/128`: the checks that guard attribution now say what they check

‎VERSIONS.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ never pin a version hex marks as retired or vulnerable.
7575
| `Tauri CLI` | **2.11.4** | 📐 `_build/_tauri/bin/cargo-tauri tauri --version` | Measured at Slice 001 line 3. Not on `PATH` and not pinned by any file in the tree: `ex_tauri` provisions it with `cargo install tauri-cli --version ^2 --root .` inside `_build/_tauri`, which is gitignored, so `cargo tauri --version` exits 101 on a fresh machine. 📐 rather than ✅ because nothing at this sha verifies it. The `^2` floats; 2.11.4 is what it resolved to on 2026-09-06. |
7676
| `Zig` | **0.16.0** | ✅ `.tool-versions` | Measured at Slice 001 line 3: burrito 1.6.0 compares Zig for **equality**, not a range (`@zig_version_expected` in `deps/burrito/lib/burrito.ex`), and exits 1 on any other version. `zig version` reports 0.16.0, exit 0. Installed through the asdf zig plugin, added this slice. Corrected 2026-09-06: this row previously read `version required by Burrito | ✅ .tool-versions` and that file carried no zig line. |
7777
| `FIPS leg base image (UBI9)` | **sha256:9295c5c688f487fa5cf27a734fa55ecd57aeb7dc0904ba537da4f42dfa1d0acb** | ✅ `ci/fips/Containerfile` | Added at Slice 003: the base of the FIPS build leg's image (docs/fips-leg.md), `registry.access.redhat.com/ubi9/ubi:latest` resolved by digest on 2026-09-20 (Red Hat Enterprise Linux release 9.8, `openssl-libs 3.5.8-1.el9_8`, `openssl-fips-provider-so 3.0.7-11.el9_8`). The image itself is OTP 28.5.0.5 built from source with `--enable-fips` against that OpenSSL, plus Elixir 1.20.4; the two archives are pinned by SHA-256 in the Containerfile and the image tag is `scripts/fips_image_tag.sh` over `.tool-versions` and the Containerfile. The FIPS provider the image runs is what the distribution ships and names; docs/fips-leg.md states what Trinity does and does not claim about it. |
78+
| `Headless image runtime base (UBI9 micro)` | **sha256:932aec77f5b86a5dba854a18b14a38725b296967e7dc9c7a1f4d7f0bf82e1ce5** | ✅ `ci/headless/bases.env` | Added at Slice 130: the final stage of the hardened headless image as this project publishes it (`ci/ironbank/Dockerfile`, docs/regulated/headless-image.md), `registry.access.redhat.com/ubi9/ubi-micro` 9.8. The digest is the one Iron Bank's own ubi9-micro image declares as its source, so the published build and an Iron Bank build start from the same bytes. Iron Bank builds the same Dockerfile from its own registry with the tags in `ci/ironbank/hardening_manifest.yaml`. |
79+
| `Headless image builder base (UBI9)` | **sha256:c26665a762876d5de27d1ed50e029ab3bc80ef3a3e155824ee7ab43e70055b63** | ✅ `ci/headless/bases.env` | Added at Slice 130: the builder stages of the hardened headless image (OTP from source, the release, the runtime root filesystem), `registry.access.redhat.com/ubi9` 9.8, the digest Iron Bank's own ubi9 image declares as its source. Nothing from these stages reaches the final image except the release and the root filesystem `dnf --installroot` assembles. |
80+
| `Image vulnerability scanner (grype)` | **v0.119.0** | ✅ `.github/workflows/headless-image.yml` | Added at Slice 130 and chosen by measurement over Trivy 0.74.0: grype identifies the Erlang/OTP runtime inside a release from `beam.smp` and matches its advisories, which Trivy does not see at all (slice 130 NOTES, D-130-6). Pinned by image digest in the workflow, and to a release at least two weeks old when chosen. |
81+
| `SCAP Security Guide` | **0.1.82** | ✅ `scripts/stig_scan.sh` | Added at Slice 130: the datastream (`ssg-rhel9-ds.xml`) and DISA STIG profile OpenSCAP evaluates the headless image against, the profile Iron Bank's pipeline uses for a UBI9 image. The release zip is checked against its published SHA-512, pinned beside the version in the script. |
7882

7983
### Core libraries
8084

‎ci/headless/Containerfile‎

Lines changed: 0 additions & 50 deletions
This file was deleted.

‎ci/headless/bases.env‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# The bases of the headless image this project publishes, as build arguments for
5+
# ci/ironbank/Dockerfile (slice 130). Iron Bank builds the same file with its own registry and
6+
# the tags in hardening_manifest.yaml; this build pins every base by digest instead, through the
7+
# tag argument, so a base change is a diff here and never a silent drift under a moving tag.
8+
# `test/ironbank_submission_test.exs` resolves every FROM in the Dockerfile against this file and
9+
# fails on one that does not carry a digest.
10+
#
11+
# Both digests are the ones Iron Bank's own UBI 9.8 images declare as their sources
12+
# (dsop/redhat/ubi/9.x/ubi9-micro and .../ubi9, hardening_manifest.yaml, read 2026-10-08), so
13+
# this build and theirs start from the same bytes.
14+
BASE_REGISTRY=registry.access.redhat.com
15+
BASE_IMAGE=ubi9/ubi-micro
16+
BASE_TAG=9.8@sha256:932aec77f5b86a5dba854a18b14a38725b296967e7dc9c7a1f4d7f0bf82e1ce5
17+
BUILDER_IMAGE=ubi9
18+
BUILDER_TAG=9.8@sha256:c26665a762876d5de27d1ed50e029ab3bc80ef3a3e155824ee7ab43e70055b63

‎ci/headless/image_allowances.yaml‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Findings of `mix trinity.image.inspect` that the headless image carries on purpose (slice 130,
5+
# AC6; NOTES D-130-10). Only four kinds can be allowed: certificate, documentation, build_library
6+
# and private_key. A package manager, a build tool, shell history, a SUID or SGID bit, a
7+
# world-writable path, a distribution cookie, /etc/passwd's mode and the image's user cannot.
8+
#
9+
# `path` is a file, or a directory prefix ending in `/`. A private_key allowance names one file
10+
# and the SHA-256 of each PEM block in it (header to footer inclusive), so another key, or a
11+
# changed one, fails again. An allowance that allows nothing in the image fails as stale: when a
12+
# reason stops being true, the row has to go.
13+
---
14+
allowances:
15+
# The operating system's trust store. openssl-libs requires ca-certificates, so the store and
16+
# its READMEs arrive with the libraries the release links. Public CA certificates, which the
17+
# runtime verifies model providers' TLS servers against; Iron Bank's own ubi9-micro carries the
18+
# same store "for default certificates". A deployment adds its own anchors under
19+
# /etc/pki/ca-trust/source/anchors.
20+
- path: "/etc/pki/ca-trust/"
21+
kinds: ["certificate", "documentation"]
22+
reason: "the OS trust store, installed by ca-certificates, a dependency of openssl-libs"
23+
- path: "/usr/share/pki/"
24+
kinds: ["certificate", "documentation"]
25+
reason: "the OS trust store's sources, installed by ca-certificates"
26+
27+
# castore's bundle of public CA certificates, a dependency of tokenizers. Slice 133 removes
28+
# tokenizers from the regulated image; if castore goes with it, this row goes too.
29+
- path: "/opt/trinity/lib/castore-1.0.21/priv/cacerts.pem"
30+
kinds: ["certificate"]
31+
reason: "public CA certificates bundled by castore, a dependency of tokenizers (release composition, slice 133)"
32+
33+
# jose's fixed RSA test key pair: deps/jose/src/jose_server.erl, rsa_private_key/0, which its
34+
# capability probe signs with. The private half is published in jose's source; it is the same
35+
# in every copy of jose and protects nothing. Iron Bank's rule names test material, so this is
36+
# for the owner's AC7 review to see, not to hide.
37+
- path: "/opt/trinity/lib/jose-1.11.12/ebin/jose_server.beam"
38+
kinds: ["private_key"]
39+
sha256: ["01bd092767f5183944bb4f93406659a6db0e9c3f89b664004eb8a07da83012bb"]
40+
reason: "jose's published RSA test key pair, used by its capability probe (jose_server.erl rsa_private_key/0)"
41+
42+
# Build-time libraries the release carries as applications because a dependency does not mark
43+
# them runtime: false. Release composition is slice 133's; these rows go when it removes them.
44+
- path: "/opt/trinity/lib/rustler-0.38.0/"
45+
kinds: ["build_library", "documentation"]
46+
reason: "rustler, a NIF build library, carried as a release application (release composition, slice 133)"
47+
- path: "/opt/trinity/lib/igniter-0.8.4"
48+
kinds: ["build_library"]
49+
reason: "igniter, a code generator, carried as a release application (release composition, slice 133)"
50+
- path: "/opt/trinity/lib/burrito-1.6.0"
51+
kinds: ["build_library"]
52+
reason: "burrito, the desktop packager, carried as a release application (release composition, slice 133)"
53+
- path: "/opt/trinity/lib/ex_tauri-0.2.0"
54+
kinds: ["build_library"]
55+
reason: "ex_tauri, the desktop shell's build library, carried as a release application (release composition, slice 133)"

0 commit comments

Comments
 (0)