Skip to content

Commit cedcb65

Browse files
committed
feat(s002): generate a CycloneDX bill of materials and attest release provenance
A reviewer of this tree, or a deployment holding one of its binaries, should be able to answer two questions without asking anyone: what is inside this build, and where did it come from. Until now neither had an answer in the artifact. The bill. `mix trinity.sbom` runs sbom's CycloneDX generator over mix.lock and then corrects two things about what it generated, because a bill without either correction is wrong rather than merely thinner. The first is coverage. mix.lock knows Hex dependencies and knows nothing about the Rust crates the desktop shell links, so a reader who took the bill for the whole picture would conclude there is no Rust in the product. The coverage is therefore written into the document itself as a metadata property, where anyone reading the file alone will find it, rather than into a page beside it. The second is licence identifiers, and it was found by validating rather than by reasoning. CycloneDX checks license.id against the SPDX enumeration and rejects the whole document over one bad value; a Hex package's licences are free text its author typed; and sbom emits a lone licence string as an id without checking it. yamerl declares "BSD 2-Clause" where SPDX spells "BSD-2-Clause", and the official CycloneDX validator refused the first bill this project ever generated over that single field. Any identifier not in the SPDX list vendored at priv/spdx/license-ids.txt is now recorded as a licence name instead, which is where CycloneDX puts a licence it cannot resolve. Nothing is dropped and nothing is guessed: the text is kept, it simply stops claiming to be an identifier it is not. The vendored list carries its source, its ref and its sha256, and going stale can only demote a valid id to a name, never the reverse. The gate generates the bill on every commit, so one that cannot be produced fails the build rather than being missing from a release nobody checked. Provenance. The package workflow uploads the bill beside each platform's binary and attests each artifact through GitHub's workflow identity, recorded in the public transparency log. gh attestation verify then checks the attestation in the same job that produced it and writes the result to the run summary, because an attestation nobody checks is a file rather than an assurance. Transport. One README paragraph states the TLS floor and the one message authentication code Trinity mints, pinned by tests against ssl.versions/0 and against the envelope's source rather than against the sentence. The assurance case is corrected with the rest: C8 stated in writing that this project published no bill of materials, and a gap stated in an assurance case is a promise to close it. What remains is narrower and is still stated: the crate half is slice 121's, and signing is slice 101's. Signed-off-by: Ayla Croft <aylacroft@proton.me>
1 parent bf2b52e commit cedcb65

11 files changed

Lines changed: 1072 additions & 6 deletions

File tree

‎.github/workflows/package.yml‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ jobs:
4444
package:
4545
name: ${{ matrix.name }}
4646
runs-on: ${{ matrix.os }}
47+
permissions:
48+
contents: read
49+
# Slice 002: build provenance is an attestation signed through GitHub's OIDC identity for
50+
# this workflow run, recorded in the public transparency log. `id-token` mints that
51+
# identity and `attestations` writes the record; neither grants anything else.
52+
id-token: write
53+
attestations: write
4754
strategy:
4855
fail-fast: false
4956
matrix:
@@ -319,12 +326,45 @@ jobs:
319326
[ -n "$CHILD" ] && kill "$CHILD" 2>/dev/null || true
320327
fi
321328
329+
# Slice 002. The bill of materials travels with the binary rather than being published
330+
# somewhere a reader has to go and find: whoever has the artifact has the list of what is
331+
# in it, and the bill states in its own metadata what it does not cover.
332+
- name: Generate the bill of materials
333+
run: mix trinity.sbom
334+
322335
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
323336
with:
324337
name: ${{ matrix.artifact }}
325-
path: burrito_out/${{ matrix.artifact }}
338+
path: |
339+
burrito_out/${{ matrix.artifact }}
340+
sbom.cdx.json
326341
if-no-files-found: error
327342

343+
# Build provenance: a signed statement of which workflow, at which commit, produced these
344+
# exact bytes. It proves where the artifact came from, not what it does. Verified in this
345+
# same job, because an attestation nobody checks is a file rather than an assurance.
346+
- name: Attest the artifact's provenance
347+
id: attest
348+
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
349+
with:
350+
subject-path: burrito_out/${{ matrix.artifact }}
351+
352+
- name: Verify the attestation, and record the result in the run summary
353+
env:
354+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
355+
shell: bash
356+
run: |
357+
set -o pipefail
358+
gh attestation verify "burrito_out/${{ matrix.artifact }}" \
359+
--repo "${{ github.repository }}" 2>&1 | tee attestation.txt
360+
{
361+
echo "### Build provenance: ${{ matrix.artifact }}"
362+
echo
363+
echo '```'
364+
cat attestation.txt
365+
echo '```'
366+
} >> "$GITHUB_STEP_SUMMARY"
367+
328368
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
329369
if: always()
330370
with:

‎.gitignore‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,7 @@ trinity-*.tar
4747
npm-debug.log
4848
/assets/node_modules/
4949
*.db-*
50+
51+
# Slice 002: the bill of materials is generated by the gate and attached to releases,
52+
# never committed: it is a statement about a build, not a source file.
53+
/sbom.cdx.json

‎README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,14 @@ documents themselves for consistency.
145145
Packaging as a single binary is documented in `docs/packaging.md`, with measured sizes and
146146
start-up times for each target; `docs/fips-leg.md` describes the FIPS build leg.
147147

148+
**Transport and message authentication.** Outbound connections use Erlang/OTP's TLS, whose default
149+
client versions on the pinned toolchain are TLS 1.3 and TLS 1.2: **1.2 is the floor**, and Trinity
150+
does not lower it (TLS 1.1 and 1.0 are available in the runtime but are not in the default set). The
151+
only message authentication code Trinity itself mints is the **AES-256-GCM** tag over the state
152+
envelope a held tool call carries between round trips, which is an approved algorithm in **FIPS**
153+
mode. Both statements are pinned by a test against the runtime and the source rather than against
154+
the sentence, so the line cannot outlive the fact.
155+
148156
## How the work is organised
149157

150158
Trinity is built in reviewed increments. Each one is small enough to review in one sitting and

‎VERSIONS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ never pin a version hex marks as retired or vulnerable.
8989
| `postgrex` | >= 0.0.0 (optional) | ✅ in `mix.lock` | Secondary DB driver, `optional: true` so the desktop build carries none of it; compiled in only under `TRINITY_DB=postgres`, which the CI job proves. Added at Slice 010. Was one row with pgvector; pgvector keeps its own row below. |
9090
| `pgvector` | ~> 0.4.1 | ✅ in `mix.lock` | Vectors on the Postgres path: `memories.embedding_vector vector(384)` under an HNSW cosine index, searched by `Trinity.Memory.VectorStores.Pgvector` (Slice 032). The postgres job runs on the `pgvector/pgvector:pg17` image. Split from the postgrex row at Slice 010. |
9191
| `oban` | ~> 2.24 | ✅ in `mix.lock` | Uses `Oban.Engines.Lite` on SQLite and `Oban.Engines.Basic` on Postgres (the adapter is compile time, ADR-0002, so the engine is too). ⚠️ Oban Pro Workflows/Smart engine are Postgres-only. Added at Slice 050 (2.24.1 measured on hex.pm 2026-09-22, Apache-2.0). |
92+
| `sbom` | ~> 0.11 | ✅ in `mix.lock` | CycloneDX software bill of materials, generated by the gate from mix.lock (`mix sbom.cyclonedx`). Maintained by erlef, the Erlang Ecosystem Foundation; BSD-3-Clause, CC0-1.0, MIT and Apache-2.0; 0.11.0 measured on hex.pm 2026-09-23, released 2026-09-22. Dev and test only: it ships in no artifact, and the gate validates its output, so a defect in it fails the build rather than producing a quietly wrong bill. Added at Slice 002. |
9293
| `jose` | ~> 1.11 | ✅ in `mix.lock` | JOSE (JWK, JWS, JWKS) over OTP's crypto, for the MCP resource server's token validation and the personal profile's embedded authorization server. MIT; 1.11.12 measured on hex.pm 2026-09-22. Not joken, a layer over it this tree does not need. Added at Slice 062. |
9394
| `oban_web` | ~> 2.13 | ✅ in `mix.lock` | The Oban dashboard, a LiveView mounted at /oban (dev, and behind `config :trinity, :oban_web, true` elsewhere). Apache-2.0 on hex.pm since the 2.12 line (2.13.0 measured 2026-09-22); the plan of 2026-09-05 wrote it in when it was commercial. Added at Slice 050. |
9495
| `req` | ~> 0.5 | ✅ in `mix.lock` | HTTP client. |

‎docs/10-assurance-case.md‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,19 +137,35 @@ algorithms are measured on a dedicated build rather than asserted.
137137

138138
## C8. The supply chain is controlled and the provenance of changes is established
139139

140-
**Argument.** Dependencies are pinned and audited; every change carries an attested author; and no
141-
change reaches the main branch without the full gate.
140+
**Argument.** Dependencies are pinned and audited; what is in a build is enumerated in the build;
141+
where a released binary came from can be checked by whoever holds it; every change carries an
142+
attested author; and no change reaches the main branch without the full gate.
142143

143144
**Evidence.**
144145
- Versions pinned in `VERSIONS.md` and verified against the lock file by the gate; `hex.audit` and
145146
`deps.audit` run on every commit.
147+
- A CycloneDX bill of materials is generated by the gate on every commit (`mix trinity.sbom`) and
148+
uploaded with each platform's artifacts, so whoever has the binary has the list of what is in
149+
it. The bill states its own coverage in a `metadata.properties` entry rather than in a document
150+
beside it.
151+
- Each released artifact carries build provenance attested through GitHub's workflow identity and
152+
recorded in a public transparency log, and `gh attestation verify` checks it in the same job
153+
that produced it, with its output in the run summary. An attestation nobody checks is a file
154+
rather than an assurance.
146155
- Every commit carries a Developer Certificate of Origin sign-off, enforced by a local hook and
147156
independently by CI, so a bypassed hook still fails the build.
148157
- The main branch is protected: changes merge only through a pull request with the gate green.
149158

150-
**Gap, stated rather than omitted.** The project does not yet publish a signed Software Bill of
151-
Materials. It is planned work, and until it exists this claim is weaker than current federal
152-
guidance asks for.
159+
**Gaps, stated rather than omitted.** Two remain, and they are narrower than the one this document
160+
stated before slice 002 rather than absent.
161+
162+
- The bill covers Hex dependencies resolved from `mix.lock` and does not cover the Rust crates the
163+
desktop shell links. A reader who misses the coverage property would conclude there is no Rust
164+
in the product, which is false. Slice 121 adds the crate half.
165+
- The bill is not itself signed with a project-held key, and releases are not signed. Provenance
166+
establishes where a binary was built; it does not establish that the project intended to publish
167+
it. Signing is slice 101, and until it exists this claim is weaker than current federal guidance
168+
asks for.
153169

154170
## C9. Memory safety by construction
155171

‎lib/mix/tasks/trinity.sbom.ex‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Mix.Tasks.Trinity.Sbom do
4+
@shortdoc "Generates the CycloneDX software bill of materials, and says what it is blind to"
5+
@moduledoc """
6+
Slice 002. Runs `mix sbom.cyclonedx` over `mix.lock` and writes `sbom.cdx.json`, then makes two
7+
corrections to what it generated. Neither is decoration: without the first the bill is read as
8+
saying more than it knows, and without the second it is not a valid CycloneDX document at all.
9+
10+
**What the bill covers, and what it does not.** It is generated from `mix.lock`, so it is exact
11+
for Hex dependencies and blind to the Rust crates the desktop shell links. A reader who takes it
12+
for the whole picture would conclude the product has no Rust in it, which is false. Rather than
13+
leave that to a paragraph in a document nobody reads beside the file, the coverage is written
14+
into the bill itself as a `metadata.properties` entry, where a tool or a person reading the
15+
artifact alone will find it.
16+
17+
**Licence identifiers that SPDX does not define.** CycloneDX validates `license.id` against the
18+
SPDX enumeration and rejects a bill carrying anything else, while a Hex package's `licenses`
19+
metadata is free text that its author typed. `sbom` emits a lone licence string as an `id`
20+
without checking it (`deps/sbom/lib/sbom/cyclonedx.ex`, `license: {:id, license}`), so one
21+
dependency writing `BSD 2-Clause` for `BSD-2-Clause` makes the whole document fail validation:
22+
measured, not supposed, on the first bill this project generated. Any identifier this project
23+
cannot find in the vendored SPDX list is therefore moved to `license.name`, which is where
24+
CycloneDX puts a licence it cannot resolve to an identifier. Nothing is dropped and nothing is
25+
guessed: `BSD 2-Clause` stays `BSD 2-Clause`, recorded as a name rather than asserted as an
26+
identifier it is not.
27+
28+
The gate runs this, so a bill that cannot be generated fails the build rather than being missing
29+
from a release nobody checked.
30+
"""
31+
use Boundary, classify_to: Trinity
32+
use Mix.Task
33+
34+
@output "sbom.cdx.json"
35+
@spdx_ids_path "priv/spdx/license-ids.txt"
36+
@coverage_property "trinity:coverage"
37+
@coverage_value "Hex dependencies resolved from mix.lock. This bill does NOT cover the Rust " <>
38+
"crates linked by the desktop shell (src-tauri/Cargo.lock); that half is " <>
39+
"slice 121. Absence of a component here is not evidence of its absence " <>
40+
"from the product."
41+
42+
@impl Mix.Task
43+
def run(argv) do
44+
output = Enum.at(argv, 0, @output)
45+
Mix.Task.run("sbom.cyclonedx", ["--force", "--pretty", "--output", output])
46+
annotate!(output)
47+
Mix.shell().info("trinity.sbom: #{output} written and annotated with its coverage")
48+
end
49+
50+
@doc """
51+
The SPDX licence identifiers CycloneDX accepts, as a set.
52+
53+
Read from the vendored list rather than from a dependency: see the file's own header for where
54+
it came from and for which way it fails when it goes stale.
55+
"""
56+
@spec spdx_ids() :: MapSet.t(String.t())
57+
def spdx_ids do
58+
Application.app_dir(:trinity, @spdx_ids_path)
59+
|> then(fn path -> if File.exists?(path), do: path, else: @spdx_ids_path end)
60+
|> File.read!()
61+
|> String.split("\n", trim: true)
62+
|> Enum.reject(&String.starts_with?(&1, "#"))
63+
|> MapSet.new()
64+
end
65+
66+
@doc "The property name the bill carries its coverage statement under."
67+
@spec coverage_property() :: String.t()
68+
def coverage_property, do: @coverage_property
69+
70+
@doc "The coverage statement written into the bill."
71+
@spec coverage_value() :: String.t()
72+
def coverage_value, do: @coverage_value
73+
74+
# Read, add the property, write back. A generation that produced something unreadable fails
75+
# here rather than shipping: the point of the bill is that it can be read.
76+
defp annotate!(output) do
77+
bom =
78+
case output |> File.read!() |> Jason.decode() do
79+
{:ok, %{"bomFormat" => "CycloneDX"} = bom} ->
80+
bom
81+
82+
{:ok, other} ->
83+
Mix.raise("#{output} is not a CycloneDX document: #{inspect(Map.keys(other))}")
84+
85+
{:error, reason} ->
86+
Mix.raise("#{output} is not readable as JSON: #{inspect(reason)}")
87+
end
88+
89+
metadata = Map.get(bom, "metadata", %{})
90+
91+
properties =
92+
metadata
93+
|> Map.get("properties", [])
94+
|> Enum.reject(&(Map.get(&1, "name") == @coverage_property))
95+
|> Kernel.++([%{"name" => @coverage_property, "value" => @coverage_value}])
96+
97+
annotated =
98+
bom
99+
|> Map.put("metadata", Map.put(metadata, "properties", properties))
100+
|> Map.replace_lazy("components", &normalise_licenses(&1, spdx_ids()))
101+
102+
File.write!(output, Jason.encode_to_iodata!(annotated, pretty: true))
103+
end
104+
105+
# An identifier SPDX does not define is not an identifier. It keeps its text and changes field.
106+
defp normalise_licenses(components, ids) when is_list(components) do
107+
Enum.map(components, fn component ->
108+
Map.replace_lazy(component, "licenses", fn choices ->
109+
Enum.map(choices, &normalise_choice(&1, ids))
110+
end)
111+
end)
112+
end
113+
114+
defp normalise_licenses(components, _ids), do: components
115+
116+
defp normalise_choice(%{"license" => %{"id" => id} = license} = choice, ids) do
117+
if MapSet.member?(ids, id) do
118+
choice
119+
else
120+
%{choice | "license" => license |> Map.delete("id") |> Map.put("name", id)}
121+
end
122+
end
123+
124+
defp normalise_choice(choice, _ids), do: choice
125+
end

‎lib/trinity/versions.ex‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,13 @@ defmodule Trinity.Versions do
153153
note:
154154
"Uses `Oban.Engines.Lite` on SQLite and `Oban.Engines.Basic` on Postgres (the adapter is compile time, ADR-0002, so the engine is too). ⚠️ Oban Pro Workflows/Smart engine are Postgres-only. Added at Slice 050 (2.24.1 measured on hex.pm 2026-09-22, Apache-2.0)."
155155
},
156+
%{
157+
name: "sbom",
158+
pin: "~> 0.11",
159+
lock: "sbom",
160+
note:
161+
"CycloneDX software bill of materials, generated by the gate from mix.lock (`mix sbom.cyclonedx`). Maintained by erlef, the Erlang Ecosystem Foundation; BSD-3-Clause, CC0-1.0, MIT and Apache-2.0; 0.11.0 measured on hex.pm 2026-09-23, released 2026-09-22. Dev and test only: it ships in no artifact, and the gate validates its output, so a defect in it fails the build rather than producing a quietly wrong bill. Added at Slice 002."
162+
},
156163
%{
157164
name: "jose",
158165
pin: "~> 1.11",

‎mix.exs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,9 @@ defmodule Trinity.MixProject do
216216
{:mox, "~> 1.2", only: :test},
217217
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
218218
{:sobelow, "~> 0.15", only: [:dev, :test], runtime: false},
219+
# Slice 002: the CycloneDX bill of materials the gate generates. Dev and test only:
220+
# it produces a record about the build and ships in no artifact.
221+
{:sbom, "~> 0.11", only: [:dev, :test], runtime: false},
219222
{:ex_doc, "~> 0.38", only: :dev, runtime: false},
220223
{:nimble_options, "~> 1.1"},
221224
# Slice 001 line 1, arm (a) recorded `only: :dev`. **Corrected at G4**, and the reason is
@@ -299,6 +302,9 @@ defmodule Trinity.MixProject do
299302
# a FIPS property, so measuring it outside the mode loses nothing; 024's FIPS
300303
# properties are measured by `mix test --trace test/fips`, which stays in the mode.
301304
"cmd env ERL_AFLAGS= ./scripts/prod_check.sh",
305+
# Slice 002: the bill of materials is generated by the gate, so a bill that cannot
306+
# be produced fails the build rather than being quietly missing from a release.
307+
"trinity.sbom",
302308
"credo --strict",
303309
"sobelow --exit --skip",
304310
# `cmd` runs it as its own OS process: Hex's tasks are not reliably resolvable from

0 commit comments

Comments
 (0)