Skip to content
Open
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
183 changes: 183 additions & 0 deletions .github/workflows/headless-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ on:
- ci/ironbank/**
- ci/headless/**
- scripts/headless_image.sh
- scripts/image_supply_chain.sh
- scripts/stig_scan.sh
- docs/regulated/image-verify.md
- lib/mix/tasks/trinity.ironbank.lint.ex
- lib/mix/tasks/trinity.image.*.ex
- mix.exs
Expand All @@ -44,6 +46,8 @@ permissions:
jobs:
image:
runs-on: ubuntu-latest
outputs:
image-id: ${{ steps.build.outputs.digest }}
# 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.
Expand Down Expand Up @@ -136,6 +140,22 @@ jobs:
mix trinity.image.stig --results out/stig/stig-results.xml --out out/stig/stig-applicability.md \
--image "$IMAGE" --digest "${{ steps.build.outputs.digest }}"

# Slice 131: the image the checks above ran on, for `publish`, so the published bytes are
# the checked bytes rather than a second build of the same commit.
- name: Hand the checked image to the publishing job
if: ${{ success() && github.event_name != 'schedule' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') }}
run: |
set -o pipefail
docker save "$IMAGE" | gzip -1 > out/image.tar.gz

- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: ${{ success() && github.event_name != 'schedule' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') }}
with:
name: headless-image
path: out/image.tar.gz
retention-days: 1
if-no-files-found: error

- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: ${{ !cancelled() }}
with:
Expand All @@ -147,3 +167,166 @@ jobs:
out/stig/stig-report.html
out/stig/stig-applicability.md
if-no-files-found: warn

# Slice 131 (docs/regulated/image-verify.md). The signing key is a held key, not a public
# transparency log (NOTES D-131-1): its private half and password are repository secrets, and its
# public half is ci/headless/cosign.pub, which the second step below holds them to.
publish:
needs: image
if: ${{ github.event_name != 'schedule' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') }}
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: read
# The registry push, and the signatures and attestations stored beside the image.
packages: write
# GitHub's platform provenance: `id-token` mints the workflow's identity, `attestations`
# records the statement. Neither grants anything else.
id-token: write
attestations: write
env:
IMAGE: trinity-headless:ci
# cosign v3.1.3, the release binary checked against the digest its cosign_checksums.txt gives.
COSIGN_VERSION: v3.1.3
COSIGN_SHA256: 4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71
# CycloneDX's own validator, pinned by digest: the bills are checked against the schema
# before they are attested.
CYCLONEDX_CLI: cyclonedx/cyclonedx-cli:0.33.1@sha256:252c2e26f468c25fea1e63ecde1bc3198ad6e9dbb57f5ed3236bddcb2281b3a7
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-publish-${{ hashFiles('mix.lock') }}
restore-keys: ${{ runner.os }}-mix-publish-

- name: Compile the tasks that write the bills
run: |
mix deps.get
mix compile

- name: Install cosign, checked against its release digest
run: |
curl -fsSL -o "$RUNNER_TEMP/cosign" \
"https://github.com/sigstore/cosign/releases/download/$COSIGN_VERSION/cosign-linux-amd64"
echo "$COSIGN_SHA256 $RUNNER_TEMP/cosign" | sha256sum --check -
chmod +x "$RUNNER_TEMP/cosign"
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"

- name: The signing key is the one whose public half is in the tree
env:
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
test -f ci/headless/cosign.pub || { echo "::error::ci/headless/cosign.pub is not in the tree"; exit 1; }
test -n "$COSIGN_PRIVATE_KEY" || { echo "::error::the COSIGN_PRIVATE_KEY secret is not set"; exit 1; }
mkdir -p out
cosign public-key --key env://COSIGN_PRIVATE_KEY > out/derived.pub
want=$(openssl pkey -pubin -in ci/headless/cosign.pub -outform DER | sha256sum)
got=$(openssl pkey -pubin -in out/derived.pub -outform DER | sha256sum)
test "$want" = "$got" || { echo "::error::the secret key's public half is not ci/headless/cosign.pub"; exit 1; }

- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: headless-image
path: out

- name: Push the checked image, by digest
id: push
run: |
set -o pipefail
gunzip -c out/image.tar.gz | docker load
test "$(docker image inspect "$IMAGE" --format '{{.Id}}')" = "${{ needs.image.outputs.image-id }}"
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
repo="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/trinity-headless"
docker tag "$IMAGE" "$repo:$GITHUB_SHA"
docker push "$repo:$GITHUB_SHA"
digest=$(docker image inspect "$repo:$GITHUB_SHA" --format '{{index .RepoDigests 0}}' | cut -d@ -f2)
echo "repo=$repo" >> "$GITHUB_OUTPUT"
echo "digest=$digest" >> "$GITHUB_OUTPUT"
echo "pushed $repo@$digest"

- name: The bills, each checked before it is attested
env:
REPO: ${{ steps.push.outputs.repo }}
DIGEST: ${{ steps.push.outputs.digest }}
run: |
mix trinity.image.provenance --image "$IMAGE" --commit "$GITHUB_SHA" \
--repository "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" --ref "$GITHUB_REF" \
--builder-id "$GITHUB_SERVER_URL/$GITHUB_WORKFLOW_REF" \
--invocation "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/attempts/$GITHUB_RUN_ATTEMPT" \
--out out/provenance.json
mix trinity.sbom out/sbom.cdx.json
mix trinity.image.aibom --out out/ai-bom.cdx.json --image "$REPO" --digest "$DIGEST"
for bom in sbom.cdx.json ai-bom.cdx.json; do
docker run --rm -v "$PWD/out:/w:ro" "$CYCLONEDX_CLI" validate \
--input-file "/w/$bom" --input-version v1_6 --fail-on-errors
done

- name: Sign the image and attach its provenance, SBOM and AI-BOM, with the held key
env:
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
scripts/image_supply_chain.sh attach "${{ steps.push.outputs.repo }}@${{ steps.push.outputs.digest }}" \
--key env://COSIGN_PRIVATE_KEY \
--provenance out/provenance.json --sbom out/sbom.cdx.json --aibom out/ai-bom.cdx.json

# GitHub's own build provenance for the same digest, generated and signed by the platform
# through the workflow's identity and recorded in the public Sigstore log. This is the
# mechanism ci/headless/slsa.yaml would need for a claim of Build L2.
- name: Ask GitHub for its platform provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ${{ steps.push.outputs.repo }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

- name: Verify the platform provenance
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh attestation verify "oci://${{ steps.push.outputs.repo }}@${{ steps.push.outputs.digest }}" \
--repo "$GITHUB_REPOSITORY"
echo "gh attestation verify: PASS for ${{ steps.push.outputs.digest }}"

# The page's sequence, extracted from the page, run against a layout of what was pushed, as
# a stranger with the public key would (test/image_supply_chain_test.exs runs the same text).
- name: Verify as a consumer would, with the documented sequence
env:
PUBKEY: ci/headless/cosign.pub
COMMIT: ${{ github.sha }}
REFERENCE_SBOM: out/sbom.cdx.json
run: |
set -o pipefail
cosign save --dir out/layout "${{ steps.push.outputs.repo }}@${{ steps.push.outputs.digest }}"
sed -n '/<!-- verify:begin -->/,/<!-- verify:end -->/p' docs/regulated/image-verify.md \
| sed '1,2d' | head -n -2 > out/verify.sh
LAYOUT=out/layout REPOSITORY="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \
BUILDER="$GITHUB_SERVER_URL/$GITHUB_WORKFLOW_REF" bash out/verify.sh | tee out/verify.txt
{
echo "### ${{ steps.push.outputs.repo }}@${{ steps.push.outputs.digest }}"
echo
echo '```'
cat out/verify.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: ${{ !cancelled() }}
with:
name: headless-image-published
path: |
out/provenance.json
out/sbom.cdx.json
out/ai-bom.cdx.json
out/verify.txt
if-no-files-found: warn
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,28 @@ evidence for each increment is retained by the maintainers and summarised here.

## 2026-10-08

### `slice/131`: the container image, signed, with its provenance and bills attached

An SBOM in a repository is a document; attached to an image's digest and signed, it is evidence a
holder of the image can check. The headless image's publishing job now pushes the image it
checked by digest, signs it with the project's held key, and attaches three signed statements to
that digest: build provenance naming the builder, the source repository and the exact commit, with
every other input by digest; the tree's CycloneDX bill for that commit; and an AI bill of materials
in CycloneDX's ML-BOM form, which records each model's weights digest, licence, training datasets
with their terms quoted where no licence identifier exists, and the status of its legal review. The
image carries no model today, and its AI-BOM says so.

The key is held rather than keyless, because the deployments that most need to verify an image are
the ones that cannot reach a public transparency log; the cost, that a stolen key signs without a
public record, is stated beside the decision. Verification is a command sequence in
`docs/regulated/image-verify.md` that needs cosign, jq and sha256sum and no network. The tests and
the publishing job both run that exact text, extracted from the page, and it refuses an unsigned
image, an image under a moved tag, genuine signatures moved onto another image, a layer altered in
the saved layout (which cosign alone accepts), a provenance for another commit or builder, and a
bill that differs from the tree's. The SLSA level claimed is Build L1, with the reason it is not L2,
and the gate fails if the claim is raised beyond what the workflow can justify. The publishing job
has not run yet: it waits on the project's signing key and on the image's own checks passing.

### `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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ do over the next year, and why the order is what it is.
| `docs/adr/` | Architecture decision records. One is added whenever a decision changes |
| `docs/10-assurance-case.md` | The structured argument that the security claims hold, with the evidence for each and the assumptions and limits named |
| `docs/09-standards-register.md` | One row per control a regulated deployment may ask about, with its evidence path and status |
| `docs/regulated/` | The pack for a regulated deployment: the authorization boundary, the responsibility matrix, how the headless image is built and checked, and how a holder verifies its signature, provenance and bills of materials offline (`image-verify.md`) |
| `lib/`, `test/`, `config/` | The application |
| `src-tauri/` | The native desktop shell |
| `scripts/`, `credo_checks/` | The release check, the benchmark scripts and this project's own Credo checks |
Expand Down
3 changes: 3 additions & 0 deletions VERSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ never pin a version hex marks as retired or vulnerable.
| `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. |
| `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. |
| `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. |
| `Image signing (cosign)` | **v3.1.3** | ✅ `.github/workflows/headless-image.yml` | Added at Slice 131: signs the published headless image and attaches its provenance, SBOM and AI-BOM with the project's held key (`scripts/image_supply_chain.sh`), and is the one program a consumer needs to verify them (docs/regulated/image-verify.md). The publishing job downloads the release binary and checks it against this SHA-256, which equals the release's `cosign_checksums.txt` entry for `cosign-linux-amd64`. `--offline` is deprecated in v3 in favour of `--bundle` with `--trusted-root`, and still works on this version. |
| `CycloneDX validator (cyclonedx-cli)` | **0.33.1** | ✅ `.github/workflows/headless-image.yml` | Added at Slice 131: validates the SBOM and the AI-BOM against CycloneDX 1.6's own schema before the publishing job attests them. Pinned by image digest. The schema makes an ML-BOM's dataset list optional, so the profile AC7 holds is `mix trinity.image.aibom --check`, not this. |
| `Test registry (registry:2)` | **sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373** | ✅ `test/image_supply_chain_test.exs` | Added at Slice 131: the registry the `:image_signing` tests push to, sign in and save from, on a random loopback port, removed when the tests end. Test-only; never part of an image or a release. |

### Core libraries

Expand Down
23 changes: 23 additions & 0 deletions ci/headless/ai_models.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
#
# The models the headless image carries, for its AI bill of materials (slice 131):
#
# mix trinity.image.aibom --out ai-bom.cdx.json
#
# writes a CycloneDX 1.6 ML-BOM with one machine-learning-model component per entry, and the
# publish job attests it to the image (docs/regulated/image-verify.md). An entry carries the
# model's embedding space (slice 133's Trinity.Memory.Space manifest, field for field), the
# weights file and its SHA-256, the licence, every training dataset with its licence or its
# terms quoted, the risks, the legal review's status, and the delivery: `shipped` (in the image)
# or `operator-accepted` (pulled by the operator, never shipped). The shape is the fixture's,
# test/support/fixtures/supply_chain/models.yaml.
#
# Empty, because the image carries no model today. No weights are baked into any image before
# legal review of their licence and training-data terms (the owner's decision D7, recorded in
# slice 133), and test/mix/tasks/trinity_image_aibom_test.exs refuses a `shipped` entry here
# whose legal review has not passed. The static embedder's entry is added here when slice 133
# is merged and that review has passed. The local MiniLM model is downloaded only when an
# operator asks for it, and is added as `operator-accepted` once its revision and weights digest
# are pinned.
models: []
30 changes: 30 additions & 0 deletions ci/headless/slsa.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
#
# The SLSA build level this project claims for the headless image it publishes (slice 131), with
# the reason for it and the reason it is not the next one. `mix trinity.image.slsa` reads the
# publishing workflow, works out the level its mechanism supports, and fails the quality gate
# when the claim below is higher, or is L2 or more without a run that verified. The same level is
# stated in docs/regulated/image-verify.md, and a test holds the two equal.
#
# Levels are the SLSA build track's (slsa-framework/slsa, spec/build-track-basics.md).
build_level: 1

reason: >-
Every published image carries provenance (https://slsa.dev/provenance/v1) naming the builder,
the source repository and the exact commit, with every other input by digest, signed with the
project's held key and attached to the image's digest. The build is scripted
(scripts/headless_image.sh) and runs on GitHub-hosted runners.

not_next: >-
L2 requires the build platform to generate and sign the provenance itself. This provenance is
generated by a step of the publishing job (mix trinity.image.provenance) and signed with a key
that job can read, so a compromised build step could forge it. The job also asks GitHub for its
own platform provenance (actions/attest-build-provenance, which GitHub documents as Build L2),
but that has not yet run; the claim rises to L2 when a run of it has verified, with that run
listed below. Neither path is L3: the provenance is made in the same job as the build's own
steps, not by an isolated reusable workflow.

# Workflow runs whose platform provenance verified (`gh attestation verify` passing in the
# publishing job). Required for a claim of L2 or more.
evidence: []
Loading
Loading