Skip to content

Commit 39220dc

Browse files
authored
Merge pull request #71 from ScriptKittyOS/slice/002-supply-chain-early
Slice 002: a CycloneDX bill of materials generated by the gate and shipped with every binary, and build provenance attested and verified on every packaged artifact. The assurance case's stated gap in C8 is closed for Hex dependencies and narrowed, in writing, to the Rust crates (121) and to signing (101). Signed-off-by: Ayla Croft <aylacroft@proton.me>
2 parents bf2b52e + e9780d6 commit 39220dc

12 files changed

Lines changed: 1125 additions & 8 deletions

File tree

‎.github/workflows/package.yml‎

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,47 @@ on:
4141
workflow_dispatch:
4242

4343
jobs:
44+
# Slice 002. The bill of materials is derived from `mix.lock` alone: it is the same document
45+
# whichever runner produces it, and `mix trinity.sbom` needs the project compiled in the dev
46+
# environment, which the three packaging jobs do not otherwise do. Generating it once here and
47+
# handing it to each of them keeps the bill travelling with every binary without paying for a
48+
# second full compile on three operating systems, and without giving Windows a chance to
49+
# produce a bill that differs from Linux's for no reason a reader could act on.
50+
sbom:
51+
name: bill of materials
52+
runs-on: ubuntu-latest
53+
permissions:
54+
contents: read
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+
- run: mix deps.get
64+
65+
- name: Generate the bill of materials
66+
run: mix trinity.sbom
67+
68+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
69+
with:
70+
name: sbom
71+
path: sbom.cdx.json
72+
if-no-files-found: error
73+
4474
package:
4575
name: ${{ matrix.name }}
4676
runs-on: ${{ matrix.os }}
77+
needs: sbom
78+
permissions:
79+
contents: read
80+
# Slice 002: build provenance is an attestation signed through GitHub's OIDC identity for
81+
# this workflow run, recorded in the public transparency log. `id-token` mints that
82+
# identity and `attestations` writes the record; neither grants anything else.
83+
id-token: write
84+
attestations: write
4785
strategy:
4886
fail-fast: false
4987
matrix:
@@ -319,12 +357,64 @@ jobs:
319357
[ -n "$CHILD" ] && kill "$CHILD" 2>/dev/null || true
320358
fi
321359
360+
# Slice 002. The bill of materials travels with the binary rather than being published
361+
# somewhere a reader has to go and find: whoever has the artifact has the list of what is
362+
# in it, and the bill states in its own metadata what it does not cover.
363+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
364+
with:
365+
name: sbom
366+
path: burrito_out
367+
368+
# The bill is placed beside the binary rather than listed as a second upload path, so that
369+
# both land at the root of the artifact. Listing them separately makes upload-artifact take
370+
# the workspace as their common ancestor and file the binary under `burrito_out/` inside the
371+
# zip, which is a layout change every earlier release's consumers would have to absorb for
372+
# no benefit.
373+
#
374+
# Corrected 2026-09-23, because the first version of this comment named a cause that is not
375+
# true. It said `gh run download --name` refuses the two-path layout with "would result in
376+
# path traversal". It does refuse, but the layout has nothing to do with it: gh 2.45.0 (the
377+
# version in Ubuntu's archive) raises that error for any artifact when extracting into the
378+
# current directory, including a pre-slice-002 single-file one, and passing `-D <dir>`
379+
# succeeds for both. gh 2.63.2 downloads both without `-D`. The error was measured against
380+
# unlike invocations and read as a regression it was not.
322381
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
323382
with:
324383
name: ${{ matrix.artifact }}
325-
path: burrito_out/${{ matrix.artifact }}
384+
path: |
385+
burrito_out/${{ matrix.artifact }}
386+
burrito_out/sbom.cdx.json
326387
if-no-files-found: error
327388

389+
# Build provenance: a signed statement of which workflow, at which commit, produced these
390+
# exact bytes. It proves where the artifact came from, not what it does. Verified in this
391+
# same job, because an attestation nobody checks is a file rather than an assurance.
392+
- name: Attest the artifact's provenance
393+
id: attest
394+
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
395+
with:
396+
subject-path: burrito_out/${{ matrix.artifact }}
397+
398+
- name: Verify the attestation, and record the result in the run summary
399+
env:
400+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
401+
shell: bash
402+
run: |
403+
set -o pipefail
404+
gh attestation verify "burrito_out/${{ matrix.artifact }}" \
405+
--repo "${{ github.repository }}" 2>&1 | tee attestation.txt
406+
# `gh attestation verify` draws its result with carriage returns, which the log
407+
# viewer collapses to nothing. One plain line the log keeps, reached only because
408+
# `set -e` and `pipefail` end the step on a failed verification.
409+
echo "gh attestation verify: PASS for ${{ matrix.artifact }}"
410+
{
411+
echo "### Build provenance: ${{ matrix.artifact }}"
412+
echo
413+
echo '```'
414+
cat attestation.txt
415+
echo '```'
416+
} >> "$GITHUB_STEP_SUMMARY"
417+
328418
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
329419
if: always()
330420
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: 10 additions & 2 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
@@ -178,8 +186,8 @@ rather than described by it.
178186
| Auditability | Decisions and effects are recorded in an Ed25519-signed hash chain with checkpoints and a verifier, so an operator can reconstruct what was done, by whom and under what decision. |
179187
| Untrusted content | Anything arriving from outside the machine is marked untrusted at the boundary and is never treated as instruction. |
180188
| Approved cryptography | A dedicated CI leg builds from source and runs the cryptographic properties inside a FIPS-mode container, so statements about approved algorithms are measured on that leg rather than asserted. |
181-
| Supply chain | Dependency and licence audits run on every commit; dependency versions are pinned in `VERSIONS.md` and verified against the lock file by the gate. |
182-
| Provenance | Every commit carries a Developer Certificate of Origin sign-off, enforced by a hook and independently by CI. |
189+
| Supply chain | Dependency and licence audits run on every commit; dependency versions are pinned in `VERSIONS.md` and verified against the lock file by the gate. A CycloneDX bill of materials is generated by the gate on every commit and ships beside every packaged binary, so whoever holds the artifact holds the list of what is in it; the bill states its own coverage inside the document, including what it does not cover. |
190+
| Provenance | Every commit carries a Developer Certificate of Origin sign-off, enforced by a hook and independently by CI. Every packaged binary carries build provenance attested through the workflow's own identity and recorded in a public transparency log, verified in the same run that produced it, so a holder can check where the bytes were built without trusting this page. |
183191
| Independent self-certification | The project holds the [OpenSSF Best Practices passing badge](https://www.bestpractices.dev/projects/14772), assessed against the Open Source Security Foundation's published criteria for basics, change control, reporting, quality, security and analysis. Three suggested criteria are recorded as unmet rather than stretched: semantic versioning, which begins at the first supported release, and two concerning dynamic analysis tooling, which the project does not yet run. |
184192
| Argued, not asserted | `docs/10-assurance-case.md` decomposes the top-level security claim into ten claims, each with its argument, the evidence a reader can check, and the limit on what it covers; the assumptions the case rests on are named rather than implied. |
185193
| Claim discipline | `docs/09-standards-register.md` records one row per control a regulated deployment might ask about, each with an evidence path and a status. No public claim about a regulation or requirement is made without a row there saying it is true. |

‎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. |

‎coverage.tsv‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ slice_id percent sha date
2222
060 80.32 49f40a4 2026-09-21
2323
061 80.05 c6b6faa 2026-09-22
2424
050 80.31 c01466d 2026-09-22
25+
002 80.31 d65c48c 2026-09-23

‎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

0 commit comments

Comments
 (0)