From d9df347e3d87ab94761137c5ebcefb66f6913b2c Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Thu, 8 Oct 2026 06:40:40 -0400 Subject: [PATCH] feat(s131): signing, provenance, SBOM and AI-BOM for the headless image Slice 131, stacked on slice 130 (PR #117). - Signing with a held key (cosign P-256, any key reference including KMS or PKCS#11), without a transparency log, so a disconnected or classified site needs only the public key (D-131-1). The cost: a stolen key signs silently, so one job holds it, and GitHub's logged platform provenance is requested beside it for connected consumers. - SLSA v1 provenance naming the builder, the repository, the commit, every hardening-manifest resource and both bases by digest. An image whose revision label is empty or names another commit is refused. - The SBOM attestation is mix trinity.sbom for the same commit, matched on the image. - A CycloneDX 1.6 ML-BOM from ci/headless/ai_models.yaml: - weights digest, licence, and every training dataset with its SPDX id or quoted terms; - the risk and the legal review status as a consideration; - the embedding space's manifest. The image's models file lists no models until slice 133 merges and the owner's legal review (D7) clears. - The level claim is Build L1 (ci/headless/slsa.yaml); a check fails if it is raised beyond what the workflow's mechanism supports. - docs/regulated/image-verify.md is the verification a stranger runs, offline, with cosign, jq and sha256sum. Each step has its own exit status. The tests and the workflow run that exact block, so the page cannot drift. - The publish job runs only after the image job passes, and only on main, a v* tag or by hand. - Two cosign behaviours were measured and are checked around: cosign verify passes an image that carries only an attestation, and it does not check layer blobs. Proven on a clean build of the real hardened image. A flipped layer bit exits 10 where cosign verify alone exits 0. The image also inherited UBI9 micro's revision and created labels (F-131-1), so both are now always set. Red evidence R1 to R7 is in the slice NOTES. Signed-off-by: Ayla Croft --- .github/workflows/headless-image.yml | 183 ++++++ CHANGELOG.md | 22 + README.md | 1 + VERSIONS.md | 3 + ci/headless/ai_models.yaml | 23 + ci/headless/slsa.yaml | 30 + docs/09-standards-register.md | 7 + docs/regulated/README.md | 1 + docs/regulated/headless-image.md | 10 + docs/regulated/image-verify.md | 237 ++++++++ lib/mix/tasks/trinity.image.aibom.ex | 538 ++++++++++++++++++ lib/mix/tasks/trinity.image.provenance.ex | 199 +++++++ lib/mix/tasks/trinity.image.slsa.ex | 193 +++++++ lib/trinity/versions.ex | 30 + scripts/headless_image.sh | 40 +- scripts/image_supply_chain.sh | 84 +++ test/image_supply_chain_test.exs | 486 ++++++++++++++++ test/mix/tasks/trinity_image_aibom_test.exs | 334 +++++++++++ .../tasks/trinity_image_provenance_test.exs | 129 +++++ test/mix/tasks/trinity_image_slsa_test.exs | 176 ++++++ .../support/fixtures/supply_chain/models.yaml | 114 ++++ test/test_helper.exs | 9 +- 22 files changed, 2847 insertions(+), 2 deletions(-) create mode 100644 ci/headless/ai_models.yaml create mode 100644 ci/headless/slsa.yaml create mode 100644 docs/regulated/image-verify.md create mode 100644 lib/mix/tasks/trinity.image.aibom.ex create mode 100644 lib/mix/tasks/trinity.image.provenance.ex create mode 100644 lib/mix/tasks/trinity.image.slsa.ex create mode 100755 scripts/image_supply_chain.sh create mode 100644 test/image_supply_chain_test.exs create mode 100644 test/mix/tasks/trinity_image_aibom_test.exs create mode 100644 test/mix/tasks/trinity_image_provenance_test.exs create mode 100644 test/mix/tasks/trinity_image_slsa_test.exs create mode 100644 test/support/fixtures/supply_chain/models.yaml diff --git a/.github/workflows/headless-image.yml b/.github/workflows/headless-image.yml index 44acefd..faee299 100644 --- a/.github/workflows/headless-image.yml +++ b/.github/workflows/headless-image.yml @@ -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 @@ -43,6 +45,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. @@ -132,6 +136,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: @@ -143,3 +163,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 '//,//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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8215e47..e02cc16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index f53a544..59ad015 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,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 | diff --git a/VERSIONS.md b/VERSIONS.md index 55f2a32..1213557 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -79,6 +79,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 diff --git a/ci/headless/ai_models.yaml b/ci/headless/ai_models.yaml new file mode 100644 index 0000000..9afd946 --- /dev/null +++ b/ci/headless/ai_models.yaml @@ -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: [] diff --git a/ci/headless/slsa.yaml b/ci/headless/slsa.yaml new file mode 100644 index 0000000..8eea76e --- /dev/null +++ b/ci/headless/slsa.yaml @@ -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: [] diff --git a/docs/09-standards-register.md b/docs/09-standards-register.md index 57a5de6..85edf31 100644 --- a/docs/09-standards-register.md +++ b/docs/09-standards-register.md @@ -75,6 +75,13 @@ still hold. | Build provenance on release artifacts | GitHub attestation, verified in the workflow | slice 002 AC2 | `:unknown`; 002 landed 2026-09-23 (tag `slice/002`) and is approved, so the gate on this row is gone and the status is the owner's to set | owner | | Signed releases, Scorecard, SLSA level | slice 121 | none yet | `not claimed` | owner | | Cryptography bill of materials | none until the minimum elements are published | none | `not claimed` | owner | +| Signed container image, verifiable with no network | the headless image's digest signed with the project's held key (cosign, no public log); the consumer's command sequence in `docs/regulated/image-verify.md`, which accepts the genuine image and exits non-zero on a tampered one | `test/image_supply_chain_test.exs` (runs the page's sequence, tagged `:image_signing`); slice 131 | the mechanism is a `tree property`, held by tests with keys made for the test. **No image has been published**: the publishing job waits on the project's key and on the image's checks passing, so no claim is made about a published image | owner | +| Build provenance on the container image, at a stated SLSA level | SLSA provenance v1 naming the builder, the repository and the commit, attested to the digest with the held key; GitHub's platform provenance requested beside it | `ci/headless/slsa.yaml`, `mix trinity.image.slsa` (fails when the claim exceeds the workflow's mechanism); slice 131 | **SLSA Build L1** claimed for the images the publishing job produces, with the reason it is not L2 in the claim file; rises only with a verified run cited. None has been produced yet (row above) | owner | +| SBOM and AI-BOM attestations on the container image | the tree's CycloneDX bill for the commit, and a CycloneDX ML-BOM of the models the image carries, each attested to the digest | `test/mix/tasks/trinity_image_aibom_test.exs`, `test/image_supply_chain_test.exs`; slice 131 | `tree property` for the mechanism; the image carries no model today, so its AI-BOM lists none | owner | + +Added 2026-10-08 (slice 131): the three rows above for the container image. The row "Signed releases, +Scorecard, SLSA level" stays as written and now covers the desktop release (slice 121); the container image +is the three rows above it. ## Architecture arguments a deployment may ask for diff --git a/docs/regulated/README.md b/docs/regulated/README.md index ba45354..3c21afa 100644 --- a/docs/regulated/README.md +++ b/docs/regulated/README.md @@ -65,6 +65,7 @@ model prompt and what may not. Read both before the others. | `crypto-inventory.md` | Every cryptographic capability, its module, and its certificate status | | `headless-image.md` | How the container image is built and hardened, and the checks run against it | | `stig-applicability.md` | The image's STIG applicability statement, generated from OpenSCAP's evaluation of it | +| `image-verify.md` | How to verify the published image's signature, build provenance, SBOM and AI-BOM, offline, with the SLSA level claimed and why | ## Status of this pack diff --git a/docs/regulated/headless-image.md b/docs/regulated/headless-image.md index 8ca6904..fece59c 100644 --- a/docs/regulated/headless-image.md +++ b/docs/regulated/headless-image.md @@ -32,6 +32,11 @@ docker run --rm -p 4000:4000 -v trinity-data:/data \ -e TRINITY_MCP_SERVER_TOKEN= trinity-headless:local ``` +The build labels the image with the manifest's OCI labels and `org.opencontainers.image.source`, +and with `org.opencontainers.image.revision`, the commit, only when no tracked file differs from +that commit; the labels go on the build command, because Iron Bank's lint refuses `LABEL` in the +Dockerfile. + The build reaches no network. Every input that is not a UBI package (the OTP and Elixir sources, Hex and rebar3, the asset tools, and today's precompiled native pieces) is a file in the build context, named in the manifest with its SHA-256 or SHA-512. dnf is the one exception, and reaches @@ -67,6 +72,11 @@ daily, and on demand, and uploads the scan, the findings and the STIG results as | Vulnerabilities | grype, then `mix trinity.image.findings --scan grype.json` | a High or Critical finding has no row in `ci/headless/justifications.yaml`, or a row justifies a finding the scan no longer reports | | STIG applicability | `scripts/stig_scan.sh IMAGE OUT`, then `mix trinity.image.stig --results OUT/stig-results.xml` | a rule of the DISA STIG profile has no disposition, or a disposition is kept for a rule that no longer needs one | +When every check passes, on `main`, on a `v*` tag or on demand, the workflow's `publish` job +pushes that same image by digest, signs it with the project's held key, attaches its build +provenance, SBOM and AI-BOM, and verifies the result the way a consumer would. +`image-verify.md` is that consumer's command sequence, and states the SLSA level claimed and why. + ### Allowances, and why there are any Some findings of the layer check are the image working as intended, and diff --git a/docs/regulated/image-verify.md b/docs/regulated/image-verify.md new file mode 100644 index 0000000..98b5619 --- /dev/null +++ b/docs/regulated/image-verify.md @@ -0,0 +1,237 @@ + + +# Verifying the headless image + +How someone who has the container image and nothing else from this project checks who built it, +from which commit, and what it contains. The checks need three programs, `cosign`, `jq` and +`sha256sum`, and no network: they run against an OCI image layout on disk, so a disconnected site +verifies exactly what a connected one does. + +## What this is not + +**A signature is evidence, not an authorization.** It says this project's key signed statements +about these exact bytes; it decides nothing about whether they may run in your environment. That +decision is yours and your authorizing official's. Nothing on this page is an authority to +operate, a compliance determination, or a claim that the image is free of vulnerabilities; the +image's scan and its STIG applicability statement are in `headless-image.md`. + +## What is attached to each published image + +The publishing job (`.github/workflows/headless-image.yml`, job `publish`) pushes the image by +digest and attaches four statements, each signed with the project's held key and stored in the +registry as an OCI referrer of the image's digest (`scripts/image_supply_chain.sh`): + +| Statement | Predicate type | What it says | +|---|---|---| +| Signature | `https://sigstore.dev/cosign/sign/v1` | the key holder signed this digest | +| Build provenance | `https://slsa.dev/provenance/v1` | the builder, the source repository, the exact commit, and every other input by digest (`mix trinity.image.provenance`) | +| SBOM | `https://cyclonedx.org/bom` | the tree's CycloneDX bill for that commit (`mix trinity.sbom`), unchanged | +| AI-BOM | `https://github.com/ScriptKittyOS/Trinity/blob/main/docs/regulated/image-verify.md#ai-bom` | a CycloneDX ML-BOM of the models the image carries (`mix trinity.image.aibom`) | + +The image also names its commit in its own configuration, as the label +`org.opencontainers.image.revision`. The build applies it only when no tracked file differed from +that commit, and the provenance is refused for an image without it. + +Beside these, the job asks GitHub for its own build provenance for the same digest, signed through +the workflow's identity and recorded in the public Sigstore log, which a connected consumer can +check with `gh attestation verify oci://IMAGE@DIGEST --repo ScriptKittyOS/Trinity`. + +**Status: the publishing job has not run yet.** It waits on two things: the project's signing key +(below), and the image's own checks passing, because nothing is published while they fail. Until +then no published image exists to verify, and the sequence below has been run against images +signed with keys made for the test. + +### Build provenance and SLSA: the level claimed, and why + +**Claimed: SLSA Build L1** for the provenance you verify with the project's public key. It exists, +is signed, and names the builder, the repository and the commit, which is what L1 asks for. It is +**not L2**, because L2 requires the build platform itself to generate and sign the provenance, and +this provenance is generated by a step of the build job and signed with a key that job can read: a +compromised build step could write a false one. GitHub's own provenance beside it is generated and +signed by the platform, which GitHub documents as Build L2; the claim will move to L2 when a run of +the publishing job has verified it, and not before. Neither path is L3, which needs the provenance +made in isolation from the build's own steps. + +The claim lives in `ci/headless/slsa.yaml`, and `mix trinity.image.slsa` fails the quality gate if +it is raised above what the workflow's mechanism supports, or to L2 or more without a run that +verified. + +### The signing key, and why there is no public transparency log + +The image is signed with a **held key** (ECDSA P-256, cosign's format), not keylessly through a +public transparency log. The reason is the deployment this matters most for: a disconnected or +classified site cannot reach a public log, and a keyless signature needs one at signing time and a +copy of the log's trusted root, kept current, at verification time. With a held key the public key +is all a verifier needs, connected or not, and a site that re-signs images inside its own boundary +can use the same method with its own key. + +What that costs, stated rather than left to be discovered: + +* **A stolen key signs silently.** No log records each signature, so misuse is not visible to + anyone. The key is held only by the publishing job, in the repository's secrets or an HSM. + A connected consumer can also require GitHub's logged provenance, which a thief holding only the + key cannot produce; a disconnected consumer has the key signature alone, and that is the + residual risk. +* **Rotation and revocation are out of band.** A new key is a new public key in this repository, + announced in the changelog; there is no log to revoke against. + +**The project's public key is not in the tree yet.** It will be `ci/headless/cosign.pub`, committed +by the maintainers when the key is generated, and the publishing job refuses to sign with a key +whose public half differs from that file. + +### What crosses to whom when an image is published + +| Destination | What is sent | Raw or hashed | +|---|---|---| +| The repository's container registry (`ghcr.io`) | the image, the four signed statements above | raw: they are what is published | +| GitHub's attestation store and the public Sigstore log | GitHub's provenance: the repository, the workflow, the commit, the image digest | raw: public by design | +| Anything else | nothing; the held-key signatures are not uploaded to any log | | + +## Verifying + +### Get the image as a layout + +From a registry you can reach, by digest, never by tag: + +``` +cosign save --dir trinity-layout ghcr.io/scriptkittyos/trinity-headless@sha256: +``` + +The directory carries the image, its four statements, and nothing else, and can be carried into a +disconnected site as it is. Verify it there, then load it into your registry by digest. + +### The sequence + +Set the inputs, then run the block below in `bash`. The block is the test: +`test/image_supply_chain_test.exs` and the publishing job both extract this exact text from this +file and run it, so it cannot drift from what works. + +| Variable | What it is | +|---|---| +| `LAYOUT` | the directory `cosign save` wrote | +| `PUBKEY` | the project's public key file | +| `COMMIT` | the full commit you expect the image to come from (a release tag's commit) | +| `REPOSITORY` | `https://github.com/ScriptKittyOS/Trinity` | +| `BUILDER` | `https://github.com/ScriptKittyOS/Trinity/.github/workflows/headless-image.yml@`, the workflow at the ref that built it | +| `REFERENCE_SBOM` | optional: the bill you generated yourself from `COMMIT` (`git checkout COMMIT && mix deps.get && mix trinity.sbom`) | +| `COSIGN` | optional: the cosign program, default `cosign` | + +Each step prints `OK` and what it established, or `FAIL` with the reason and exits with its own +status: 10 the layout, 11 the signature, 12 the provenance's signature, 13 the commit, 14 the +builder or repository, 15 the SBOM's signature, 16 the SBOM's contents, 17 the AI-BOM. + + +```bash +set -euo pipefail +: "${LAYOUT:?}" "${PUBKEY:?}" "${COMMIT:?}" "${REPOSITORY:?}" "${BUILDER:?}" +COSIGN="${COSIGN:-cosign}" +AIBOM_TYPE="https://github.com/ScriptKittyOS/Trinity/blob/main/docs/regulated/image-verify.md#ai-bom" +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT +fail() { echo "FAIL $1: $2" >&2; exit "$3"; } +ok() { echo "OK $1: $2"; } +blob() { echo "$LAYOUT/blobs/sha256/${1#sha256:}"; } +offline=(--key "$PUBKEY" --offline --insecure-ignore-tlog=true --local-image) + +# 1. The layout: every blob is the bytes its name says, and the image's blobs are all present. +# cosign checks the manifest's digest, not the layers it lists, so this step is not optional. +(cd "$LAYOUT/blobs/sha256" && for f in *; do printf '%s %s\n' "$f" "$f"; done | + sha256sum --quiet --check - >"$WORK/blobs.txt" 2>&1) || + fail layout "a blob does not match its digest: $(head -1 "$WORK/blobs.txt")" 10 +[ "$(jq '.manifests | length' "$LAYOUT/index.json")" = 1 ] || + fail layout "index.json must name exactly one image" 10 +DIGEST=$(jq -r '.manifests[0].digest' "$LAYOUT/index.json") +[ -f "$(blob "$DIGEST")" ] || fail layout "the image manifest $DIGEST is missing" 10 +for d in $(jq -r '.config.digest, .layers[].digest' "$(blob "$DIGEST")"); do + [ -f "$(blob "$d")" ] || fail layout "the image's blob $d is missing" 10 +done +ok layout "every blob matches its digest; the image is $DIGEST" + +# 2. The signature: a statement signed by the key names this digest. +"$COSIGN" verify "${offline[@]}" "$LAYOUT" >"$WORK/signature.json" 2>"$WORK/err" || + fail signature "$(tail -1 "$WORK/err")" 11 +ok signature "signed by the key in $PUBKEY" + +# Each attestation of a type, as the statements cosign verified, one JSON array. +statements() { + "$COSIGN" verify-attestation "${offline[@]}" --type "$1" "$LAYOUT" >"$WORK/att.jsonl" 2>"$WORK/err" || + return 1 + jq -s '[.[] | .payload | @base64d | fromjson]' "$WORK/att.jsonl" >"$2" + jq -e --arg d "${DIGEST#sha256:}" 'length > 0 and all(.[]; [.subject[].digest.sha256] == [$d])' "$2" >/dev/null +} + +# 3. The provenance: signed by the key, naming the commit, the builder and the repository. +statements slsaprovenance1 "$WORK/provenance.json" || + fail provenance "no provenance for $DIGEST signed by the key: $(tail -1 "$WORK/err")" 12 +CONFIG=$(jq -r '.config.digest' "$(blob "$DIGEST")") +LABEL=$(jq -r '.config.Labels["org.opencontainers.image.revision"] // ""' "$(blob "$CONFIG")") +[ "$LABEL" = "$COMMIT" ] || + fail commit "the image says it was built from '$LABEL', not $COMMIT" 13 +jq -e --arg c "$COMMIT" 'all(.[]; [.predicate.buildDefinition.resolvedDependencies[] + | select(.uri | startswith("git+")) | .digest.gitCommit] == [$c])' \ + "$WORK/provenance.json" >/dev/null || + fail commit "the provenance names another commit than $COMMIT" 13 +ok commit "the image and its provenance both name $COMMIT" +jq -e --arg b "$BUILDER" --arg r "$REPOSITORY" 'all(.[]; .predicate.runDetails.builder.id == $b + and .predicate.buildDefinition.externalParameters.repository == $r)' \ + "$WORK/provenance.json" >/dev/null || + fail builder "the provenance names another builder or repository than $BUILDER, $REPOSITORY" 14 +ok builder "built by $BUILDER from $REPOSITORY" + +# 4. The SBOM: signed by the key, and, given your own bill for the commit, the same components. +statements cyclonedx "$WORK/sbom.json" || + fail sbom "no SBOM for $DIGEST signed by the key: $(tail -1 "$WORK/err")" 15 +ok sbom "$(jq '.[0].predicate.components | length' "$WORK/sbom.json") components, signed by the key" +components='[.components[]? | {group, name, version, purl, hashes}] | sort_by(.purl // "", .name)' +if [ -n "${REFERENCE_SBOM:-}" ]; then + jq "$components" "$REFERENCE_SBOM" >"$WORK/want.json" + jq ".[0].predicate | $components" "$WORK/sbom.json" >"$WORK/got.json" + diff "$WORK/want.json" "$WORK/got.json" >"$WORK/sbom.diff" || + fail sbom-match "the attested SBOM differs from $REFERENCE_SBOM: $(grep -m1 '^[<>]' "$WORK/sbom.diff")" 16 + ok sbom-match "the same components as $REFERENCE_SBOM" +else + echo "NOT CHECKED sbom-match: no REFERENCE_SBOM was given" +fi + +# 5. The AI-BOM: signed by the key; every model has a weights hash if shipped, and its datasets. +statements "$AIBOM_TYPE" "$WORK/aibom.json" || + fail aibom "no AI-BOM for $DIGEST signed by the key: $(tail -1 "$WORK/err")" 17 +jq -e 'all(.[0].predicate.components[]? | select(.type == "machine-learning-model"); + ((.modelCard.modelParameters.datasets // []) | length > 0) + and (.scope == "optional" or any(.hashes[]?; .alg == "SHA-256")))' \ + "$WORK/aibom.json" >/dev/null || + fail aibom "a model in the AI-BOM has no dataset list, or is shipped without a weights hash" 17 +ok aibom "$(jq '[.[0].predicate.components[]? | select(.type == "machine-learning-model")] | length' \ + "$WORK/aibom.json") model(s) listed, signed by the key" +``` + + +### What the steps do not establish + +* **The SBOM is the tree's bill**: every Hex package `mix.lock` resolves, tools used only to build + and test included. It does not list the operating system's packages or the Erlang runtime inside + the image; the image scan in `headless-image.md` is the evidence for those. +* **Without `REFERENCE_SBOM`, step 4 checks the bill's signature only**, and says so. Whether it is + the bill of that commit is what the comparison checks. +* **Step 5 is the shape a stranger can check with `jq`.** The full profile, every dataset's licence + or quoted terms and the embedding space's identity, is `mix trinity.image.aibom --check`, which the + publishing job runs before it attests the bill. +* **cosign's own success message** says the claims' existence in the transparency log "was verified + offline". With `--insecure-ignore-tlog=true` and a held key there is no log entry; read the step + above, not that line. + +## Build type + + +The provenance's `buildType` is this section's URI. A build of this type is +`scripts/headless_image.sh build` at the commit named in `resolvedDependencies`, building +`ci/ironbank/Dockerfile` with the build arguments in `ci/headless/bases.env` (both bases pinned by +digest) from a context in which every resource of `ci/ironbank/hardening_manifest.yaml` was checked +against its digest. `externalParameters` names the repository, the ref, the recipe and the bases +file; `resolvedDependencies` lists the commit, every resource and both bases by digest. + + +The AI-BOM's predicate type is this section's URI; the predicate is a CycloneDX 1.6 document whose +`metadata.properties` carry `trinity:bom-kind` = `ai-bom`. `ci/headless/ai_models.yaml` is its +source, and says why it lists no model today: no weights are baked into any image before legal +review of their licence and training-data terms. diff --git a/lib/mix/tasks/trinity.image.aibom.ex b/lib/mix/tasks/trinity.image.aibom.ex new file mode 100644 index 0000000..8e42c7b --- /dev/null +++ b/lib/mix/tasks/trinity.image.aibom.ex @@ -0,0 +1,538 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Mix.Tasks.Trinity.Image.Aibom do + @shortdoc "Writes the headless image's AI-BOM (CycloneDX ML-BOM), or checks one (slice 131, AC7)" + + @moduledoc """ + The image's AI bill of materials: a CycloneDX 1.6 document with one `machine-learning-model` + component for each model the image carries, from `ci/headless/ai_models.yaml` + (`docs/regulated/image-verify.md`). + + mix trinity.image.aibom --out ai-bom.cdx.json [--models PATH] [--image NAME] [--digest sha256:HEX] + mix trinity.image.aibom --check ai-bom.cdx.json + + Writing checks what it wrote, so a bill that would fail the check is never written. + + **What a model component carries.** The weights file's SHA-256 as the component hash; the + model's licence; each training dataset as a `data` component with its licence, or, where a + dataset's terms have no SPDX identifier (MS MARCO's "non-commercial research purposes only"), + the terms quoted as the licence text; `modelCard.considerations` naming each risk, with the + legal review's status as its mitigation; and the embedding space it defines, as two + properties: slice 133's `Trinity.Memory.Space` manifest in RFC 8785 canonical JSON + (`trinity:space:manifest`) and the space ID, which is the SHA-256 of exactly those bytes. + + **What the check refuses**, beyond what CycloneDX's own schema requires (its schema makes the + dataset list optional, so it cannot be what holds AC7): a model with no SHA-256, no licence or + no dataset; a dataset reference that resolves to no `data` component, or one with no licence, + no SPDX id and no quoted terms; a model with no risk or no legal review status; a delivery other + than `shipped` or `operator-accepted`, or an operator-accepted model claimed as required (it is + never shipped: the operator pulled it); a dataset licence left `unrecorded` once the legal + review has passed; a space manifest that is not the fourteen fields in canonical form, or whose + SHA-256 is not the recorded space ID; and a hash that is not the space's `weights_digest`. + + The space ID is SHA-256 over the RFC 8785 canonical JSON of the manifest, as + `Trinity.Memory.Space.id/1` computes it (slice 133). Until that slice is merged this module + carries the same computation and the same field list. + """ + + use Boundary, classify_to: Trinity + use Mix.Task + + @models "ci/headless/ai_models.yaml" + @spec_version "1.6" + @deliveries ~w(shipped operator-accepted) + @reviews ~w(pending passed refused operator) + @unrecorded "unrecorded" + @sha256 ~r/\A[0-9a-f]{64}\z/ + @serial ~r/\Aurn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/ + + # Slice 133's identity fields (`Trinity.Memory.Space.identity_fields/0`), in its order. + @space_fields ~w(model_id revision weights_digest tokenizer_digest dim pooling normalisation + quantization query_prompt document_prompt max_input runtime locality num_ctx) + + @impl Mix.Task + def run(argv) do + {opts, _, _} = + OptionParser.parse(argv, + strict: [out: :string, models: :string, image: :string, digest: :string, check: :string] + ) + + case opts[:check] do + nil -> write(opts) + path -> check_file!(path) + end + end + + defp write(opts) do + out = opts[:out] || Mix.raise("usage: mix trinity.image.aibom --out FILE | --check FILE") + path = opts[:models] || @models + bom = path |> read_models!() |> bom(image: opts[:image], digest: opts[:digest], source: path) + + case check(bom) do + [] -> + File.write!(out, Jason.encode_to_iodata!(bom, pretty: true)) + n = Enum.count(bom["components"], &(&1["type"] == "machine-learning-model")) + Mix.shell().info("trinity.image.aibom: #{out} written, #{n} model(s) from #{path}") + + found -> + fail!(found) + end + end + + defp check_file!(path) do + bom = path |> File.read!() |> Jason.decode!() + + case check(bom) do + [] -> Mix.shell().info("trinity.image.aibom: #{path} OK") + found -> fail!(found) + end + end + + defp fail!(found) do + Enum.each(found, &Mix.shell().error("FAIL AC7: #{&1}")) + Mix.raise("trinity.image.aibom: #{length(found)} violation(s)") + end + + @doc "Reads the models file: the `models` list, each entry a map with string keys." + @spec read_models!(Path.t()) :: [map()] + def read_models!(path) do + case YamlElixir.read_from_file(path) do + {:ok, %{"models" => models}} when is_list(models) -> models + {:ok, _} -> Mix.raise("#{path} has no `models` list") + {:error, reason} -> Mix.raise("#{path} is not readable YAML: #{inspect(reason)}") + end + end + + @doc """ + The entries marked `shipped` whose legal review has not passed. The owner's rule (slice 133, + D7) is that no weights are baked into an image before that review, so for the image's own + models file this list must be empty; the test that runs it over `ci/headless/ai_models.yaml` + is what holds the rule. A fixture may carry such an entry, which is why writing does not refuse it. + """ + @spec unreviewed_shipped([map()]) :: [String.t()] + def unreviewed_shipped(models) do + for %{"delivery" => "shipped"} = m <- models, + get_in(m, ["legal_review", "status"]) != "passed", + do: to_string(get_in(m, ["space", "model_id"])) + end + + @doc "The identity fields of an embedding space, as slice 133 defines them." + @spec space_fields() :: [String.t()] + def space_fields, do: @space_fields + + @doc """ + The space ID of a manifest: lowercase hex SHA-256 of its RFC 8785 canonical JSON, the same + computation as slice 133's `Trinity.Memory.Space.id/1`. + """ + @spec space_id(map()) :: String.t() + def space_id(manifest) when is_map(manifest) do + manifest |> Jcs.encode() |> then(&:crypto.hash(:sha256, &1)) |> Base.encode16(case: :lower) + end + + @doc """ + The CycloneDX ML-BOM for a list of model entries. Options: `:image` and `:digest` name the + image the bill describes, `:source` the file the entries came from. + """ + @spec bom([map()], keyword()) :: map() + def bom(models, opts \\ []) when is_list(models) do + {model_components, data_components} = + models |> Enum.map(&model_components/1) |> Enum.unzip() + + components = model_components ++ (data_components |> List.flatten() |> Enum.uniq()) + + body = %{ + "bomFormat" => "CycloneDX", + "specVersion" => @spec_version, + "version" => 1, + "metadata" => metadata(opts, length(models)), + "components" => components + } + + Map.put(body, "serialNumber", serial(body)) + end + + defp metadata(opts, count) do + image = + %{ + "type" => "container", + "bom-ref" => "image", + "name" => opts[:image] || "trinity-headless" + } + |> put_digest(opts[:digest]) + + %{ + "component" => image, + "properties" => [ + %{"name" => "trinity:bom-kind", "value" => "ai-bom"}, + %{"name" => "trinity:models-source", "value" => opts[:source] || @models}, + %{"name" => "trinity:model-count", "value" => Integer.to_string(count)}, + %{ + "name" => "trinity:coverage", + "value" => + "The models this image carries (delivery shipped) and the models it is built to " <> + "use only when an operator pulls them (delivery operator-accepted, never shipped). " <> + "A model an operator connects or imports that is not listed here is the " <> + "deployment's record, kept in its embedding space, not this bill's." + } + ] + } + end + + defp put_digest(component, nil), do: component + + defp put_digest(component, "sha256:" <> hex) do + Map.put(component, "hashes", [%{"alg" => "SHA-256", "content" => hex}]) + end + + defp put_digest(_component, other), do: Mix.raise("--digest must be sha256:HEX, got #{other}") + + # A serial number derived from the content, so the same models give the same bill. + defp serial(body) do + <> = + body |> Jcs.encode() |> then(&:crypto.hash(:sha256, &1)) |> Base.encode16(case: :lower) + + "urn:uuid:#{a}-#{b}-8#{c}-a#{d}-#{e}" + end + + defp model_components(entry) do + space = Map.get(entry, "space", %{}) + id = space_id(space) + model_id = to_string(space["model_id"]) + {group, name} = split_model_id(model_id) + review = get_in(entry, ["legal_review", "status"]) + delivery = entry["delivery"] + datasets = entry["datasets"] + + data = + if is_list(datasets), do: Enum.map(datasets, &data_component(model_id, &1)), else: [] + + model = + %{ + "type" => "machine-learning-model", + "bom-ref" => "model:" <> id, + "name" => name, + "version" => to_string(space["revision"]), + "scope" => if(delivery == "operator-accepted", do: "optional", else: "required"), + "licenses" => licences(entry["licence"]), + "properties" => + [ + %{"name" => "trinity:delivery", "value" => to_string(delivery)}, + %{"name" => "trinity:legal-review", "value" => to_string(review)}, + %{"name" => "trinity:space:id", "value" => id}, + %{"name" => "trinity:space:manifest", "value" => Jcs.encode(space)} + ] ++ weights_properties(entry["weights"]), + "modelCard" => model_card(entry, data) + } + |> put_if("group", group) + |> put_if("description", entry["description"]) + |> put_if("hashes", weights_hashes(entry["weights"])) + |> put_if("externalReferences", references(entry["source"])) + + {model, data} + end + + defp split_model_id(model_id) do + case String.split(model_id, "/", parts: 2) do + [group, name] -> {group, name} + [name] -> {nil, name} + end + end + + defp model_card(entry, data) do + parameters = + %{} + |> put_if("task", entry["task"]) + |> put_if("architectureFamily", entry["architecture"]) + |> put_if( + "datasets", + if(is_list(entry["datasets"]), do: Enum.map(data, &%{"ref" => &1["bom-ref"]})) + ) + + review = entry["legal_review"] || %{} + + status = + "Legal review of the licence and the training-data terms: " <> + to_string(review["status"]) <> + if(review["record"], do: " (#{review["record"]})", else: "") + + considerations = + case entry["risks"] do + risks when is_list(risks) and risks != [] -> + Enum.map(risks, &%{"name" => to_string(&1), "mitigationStrategy" => status}) + + _ -> + [] + end + + %{ + "modelParameters" => parameters, + "considerations" => %{"ethicalConsiderations" => considerations} + } + end + + defp data_component(model_id, %{"name" => name} = dataset) do + %{ + "type" => "data", + "bom-ref" => "dataset:" <> slug(model_id) <> ":" <> slug(name), + "name" => name, + "data" => [ + %{"type" => "dataset", "name" => name} + |> put_if("description", dataset["description"]) + ], + "licenses" => licences(dataset["licence"]), + "properties" => [%{"name" => "trinity:training-data-of", "value" => model_id}] + } + |> put_if("externalReferences", references(dataset["source"])) + end + + defp data_component(model_id, other) do + %{ + "type" => "data", + "bom-ref" => "dataset:invalid:" <> slug(inspect(other)), + "name" => inspect(other), + "data" => [], + "licenses" => [], + "properties" => [%{"name" => "trinity:training-data-of", "value" => model_id}] + } + end + + defp slug(text), do: text |> String.downcase() |> String.replace(~r/[^a-z0-9._-]+/, "-") + + # A licence is an SPDX id, a name with the terms quoted, or the word `unrecorded`. + defp licences(@unrecorded), do: [%{"license" => %{"name" => @unrecorded}}] + defp licences(%{"id" => id}), do: [%{"license" => %{"id" => id}}] + + defp licences(%{"name" => name} = licence) do + text = + case licence["text"] do + nil -> nil + content -> %{"contentType" => "text/plain", "content" => String.trim(content)} + end + + [ + %{ + "license" => %{"name" => name} |> put_if("text", text) |> put_if("url", licence["url"]) + } + ] + end + + defp licences(_), do: [] + + defp weights_hashes(%{"sha256" => hex}), do: [%{"alg" => "SHA-256", "content" => hex}] + defp weights_hashes(_), do: nil + + defp weights_properties(%{"file" => file}), + do: [%{"name" => "trinity:weights:file", "value" => file}] + + defp weights_properties(_), do: [] + + defp references(nil), do: nil + defp references(url), do: [%{"type" => "distribution", "url" => url}] + + defp put_if(map, _key, nil), do: map + defp put_if(map, key, value), do: Map.put(map, key, value) + + @doc """ + Checks a bill against the profile above. Returns the violations, each a sentence; `[]` is a + bill that passes. + """ + @spec check(map()) :: [String.t()] + def check(bom) when is_map(bom) do + components = List.wrap(bom["components"]) + by_ref = Map.new(components, &{&1["bom-ref"], &1}) + + document(bom) ++ + duplicate_refs(components) ++ + Enum.flat_map( + Enum.filter(components, &(&1["type"] == "machine-learning-model")), + &check_model(&1, by_ref) + ) + end + + defp document(bom) do + [ + {bom["bomFormat"] == "CycloneDX", "bomFormat is not CycloneDX"}, + {bom["specVersion"] == @spec_version, "specVersion is not #{@spec_version}"}, + {is_binary(bom["serialNumber"]) and Regex.match?(@serial, bom["serialNumber"]), + "serialNumber is not a urn:uuid"}, + {is_integer(bom["version"]) and bom["version"] >= 1, "version is not a positive integer"}, + {is_map(get_in(bom, ["metadata", "component"])), + "metadata.component does not name what the bill describes"}, + {is_list(bom["components"]), "components is not a list"} + ] + |> Enum.reject(&elem(&1, 0)) + |> Enum.map(&elem(&1, 1)) + end + + defp duplicate_refs(components) do + components + |> Enum.map(& &1["bom-ref"]) + |> Enum.frequencies() + |> Enum.filter(fn {_, n} -> n > 1 end) + |> Enum.map(fn {ref, _} -> "bom-ref #{ref} is used by more than one component" end) + end + + defp check_model(model, by_ref) do + label = "model #{model["group"] && model["group"] <> "/"}#{model["name"]}" + props = Map.new(List.wrap(model["properties"]), &{&1["name"], &1["value"]}) + delivery = props["trinity:delivery"] + review = props["trinity:legal-review"] + card = model["modelCard"] || %{} + datasets = get_in(card, ["modelParameters", "datasets"]) + + {manifest, space_problems} = space_violations(label, props) + + Enum.concat([ + space_problems, + delivery_violations(label, delivery, model["scope"]), + hash_violations(label, model["hashes"], delivery, manifest["weights_digest"]), + licence_violations(label, model["licenses"]), + dataset_violations(label, datasets, by_ref, review), + consideration_violations(label, get_in(card, ["considerations", "ethicalConsiderations"])), + review_violations(label, review) + ]) + end + + defp delivery_violations(label, delivery, scope) do + cond do + delivery not in @deliveries -> + ["#{label}: delivery #{inspect(delivery)} is not one of #{Enum.join(@deliveries, ", ")}"] + + delivery == "operator-accepted" and scope != "optional" -> + ["#{label}: an operator-accepted model is never shipped, so its scope must be optional"] + + delivery == "shipped" and scope != "required" -> + ["#{label}: a shipped model is in the image, so its scope must be required"] + + true -> + [] + end + end + + # The space manifest travels as its canonical JSON, so a reader can recompute the ID from the + # property alone: SHA-256 of exactly those bytes. + defp space_violations(label, props) do + with text when is_binary(text) <- props["trinity:space:manifest"], + {:ok, manifest} when is_map(manifest) <- Jason.decode(text) do + keys = manifest |> Map.keys() |> Enum.sort() + id = :crypto.hash(:sha256, text) |> Base.encode16(case: :lower) + + problems = + [ + {keys == Enum.sort(@space_fields), + "#{label}: the space manifest's fields are #{inspect(keys)}, not slice 133's fourteen"}, + {Jcs.encode(manifest) == text, + "#{label}: the space manifest is not in RFC 8785 canonical form"}, + {props["trinity:space:id"] == id, + "#{label}: trinity:space:id is not the SHA-256 of the space manifest (#{id})"} + ] + |> Enum.reject(&elem(&1, 0)) + |> Enum.map(&elem(&1, 1)) + + {manifest, problems} + else + _ -> {%{}, ["#{label}: no embedding space manifest (trinity:space:manifest)"]} + end + end + + defp hash_violations(label, hashes, delivery, digest) do + sha256 = + Enum.find_value(List.wrap(hashes), fn + %{"alg" => "SHA-256", "content" => hex} when is_binary(hex) -> hex + _ -> nil + end) + + cond do + delivery == "shipped" and is_nil(sha256) -> + ["#{label}: no SHA-256 of the weights the image carries"] + + is_nil(sha256) -> + [] + + not Regex.match?(@sha256, sha256) -> + ["#{label}: the SHA-256 #{inspect(sha256)} is not 64 lowercase hex characters"] + + sha256 != digest -> + ["#{label}: the weights hash #{sha256} is not the space's weights_digest #{digest}"] + + true -> + [] + end + end + + defp licence_violations(label, licences) do + if Enum.any?(List.wrap(licences), &licence_named?/1), + do: [], + else: ["#{label}: no licence"] + end + + defp licence_named?(%{"license" => %{"id" => id}}) when is_binary(id) and id != "", do: true + + defp licence_named?(%{"license" => %{"name" => name}}) when is_binary(name) and name != "", + do: true + + defp licence_named?(_), do: false + + defp dataset_violations(label, datasets, by_ref, review) do + case datasets do + [_ | _] -> Enum.flat_map(datasets, &dataset_ref_violations(label, &1, by_ref, review)) + _ -> ["#{label}: no training dataset is listed (modelCard.modelParameters.datasets)"] + end + end + + defp dataset_ref_violations(label, %{"ref" => ref}, by_ref, review) do + case by_ref[ref] do + %{"type" => "data"} = data -> data_licence_violations(label, data, review) + _ -> ["#{label}: dataset reference #{ref} resolves to no data component"] + end + end + + defp dataset_ref_violations(label, other, _by_ref, _review), + do: ["#{label}: dataset entry #{inspect(other)} is not a reference to a data component"] + + defp data_licence_violations(label, data, review) do + licences = List.wrap(data["licenses"]) + name = data["name"] + + cond do + Enum.any?(licences, &match?(%{"license" => %{"name" => @unrecorded}}, &1)) -> + if review == "passed", + do: ["#{label}: dataset #{name}'s licence is unrecorded, but the legal review passed"], + else: [] + + Enum.any?(licences, &data_licence_stated?/1) -> + [] + + true -> + ["#{label}: dataset #{name} has no licence: an SPDX id, or a name with the terms quoted"] + end + end + + defp data_licence_stated?(%{"license" => %{"id" => id}}) when is_binary(id) and id != "", + do: true + + defp data_licence_stated?(%{"license" => %{"name" => _, "text" => %{"content" => text}}}) + when is_binary(text) and text != "", + do: true + + defp data_licence_stated?(_), do: false + + defp consideration_violations(label, considerations) do + named = + Enum.filter(List.wrap(considerations), fn c -> + is_binary(c["name"]) and c["name"] != "" and is_binary(c["mitigationStrategy"]) + end) + + if named == [], + do: ["#{label}: no consideration names a risk (modelCard.considerations)"], + else: [] + end + + defp review_violations(_label, review) when review in @reviews, do: [] + + defp review_violations(label, review), + do: [ + "#{label}: legal review status #{inspect(review)} is not one of #{Enum.join(@reviews, ", ")}" + ] +end diff --git a/lib/mix/tasks/trinity.image.provenance.ex b/lib/mix/tasks/trinity.image.provenance.ex new file mode 100644 index 0000000..e60eee3 --- /dev/null +++ b/lib/mix/tasks/trinity.image.provenance.ex @@ -0,0 +1,199 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Mix.Tasks.Trinity.Image.Provenance do + @shortdoc "Writes the headless image's SLSA build provenance predicate (slice 131, AC2)" + + @moduledoc """ + Writes a SLSA provenance v1 predicate (`https://slsa.dev/provenance/v1`) for a built headless + image, for `scripts/image_supply_chain.sh` to attest to the image's digest + (`docs/regulated/image-verify.md`). + + mix trinity.image.provenance --image IMAGE --commit SHA --repository URL \\ + --builder-id URI [--ref REF] [--invocation URI] --out provenance.json + + It names **the builder** (`runDetails.builder.id`: the workflow's ref when CI builds), **the + source repository** (`buildDefinition.externalParameters.repository`) and **the exact source + commit** (the `git+` entry of `resolvedDependencies`, as `gitCommit`), and lists every other + input the build took by digest: each resource of `ci/ironbank/hardening_manifest.yaml` and both + bases of `ci/headless/bases.env`. + + **It refuses an image that does not name the same commit.** `scripts/headless_image.sh build` + labels the image `org.opencontainers.image.revision` with the commit it was built from, and + only when no tracked file differed from that commit, since the build stages tracked files as + they are on disk; otherwise it sets the label empty, because an unset label is inherited from + the base image, and UBI9 micro's names Red Hat's commit. So an image built from a modified tree + names no commit, and no provenance can be written for it; an image built from another commit, + or one whose label is the base's, names a commit other than `--commit`, and none can be written + for it either. The consumer's sequence makes the same + comparison from the other side. + + This provenance is generated by a step of the build job and signed with a key that job holds. + It is SLSA Build L1 provenance and is not claimed as more (`ci/headless/slsa.yaml`). + """ + + use Boundary, classify_to: Trinity + use Mix.Task + + alias Mix.Tasks.Trinity.Ironbank.Lint + + @manifest "ci/ironbank/hardening_manifest.yaml" + @bases "ci/headless/bases.env" + @recipe "ci/ironbank/Dockerfile" + @revision "org.opencontainers.image.revision" + @build_type "https://github.com/ScriptKittyOS/Trinity/blob/main/docs/regulated/image-verify.md#build-type-v1" + @commit ~r/\A[0-9a-f]{40}\z/ + + @impl Mix.Task + def run(argv) do + {opts, _, _} = + OptionParser.parse(argv, + strict: [ + image: :string, + commit: :string, + repository: :string, + builder_id: :string, + ref: :string, + invocation: :string, + out: :string + ] + ) + + out = opts[:out] || usage!() + image = opts[:image] || usage!() + + params = %{ + commit: opts[:commit], + repository: opts[:repository], + builder_id: opts[:builder_id], + ref: opts[:ref] || "", + invocation: opts[:invocation], + revision_label: revision_label!(image) + } + + case refusals(params) do + [] -> + predicate = predicate(params, read_resources!(@manifest), read_bases!(@bases)) + File.write!(out, Jason.encode_to_iodata!(predicate, pretty: true)) + + Mix.shell().info( + "trinity.image.provenance: #{out} written for #{image} at #{params.commit}" + ) + + found -> + Enum.each(found, &Mix.shell().error("FAIL AC2: #{&1}")) + Mix.raise("trinity.image.provenance: #{length(found)} refusal(s)") + end + end + + defp usage! do + Mix.raise( + "usage: mix trinity.image.provenance --image IMAGE --commit SHA --repository URL " <> + "--builder-id URI [--ref REF] [--invocation URI] --out FILE" + ) + end + + defp revision_label!(image) do + case System.cmd("docker", ["image", "inspect", image, "--format", "{{json .Config.Labels}}"], + stderr_to_stdout: true + ) do + {json, 0} -> json |> Jason.decode!() |> Kernel.||(%{}) |> Map.get(@revision) + {out, status} -> Mix.raise("docker image inspect #{image} exited #{status}: #{out}") + end + end + + @doc "The label an image names its source commit by." + @spec revision_label() :: String.t() + def revision_label, do: @revision + + @doc "The `buildType` URI this tree's provenance declares." + @spec build_type() :: String.t() + def build_type, do: @build_type + + @doc """ + Why provenance must not be written for these parameters; `[]` when it may. `revision_label` is + the image's `#{@revision}` label, or nil when it has none; empty is a build that named none. + """ + @spec refusals(map()) :: [String.t()] + def refusals(params) do + commit = params[:commit] + + [ + {is_binary(commit) and Regex.match?(@commit, commit), + "--commit #{inspect(commit)} is not a full 40-character lowercase git commit"}, + {present?(params[:repository]), "--repository is required"}, + {present?(params[:builder_id]), "--builder-id is required"}, + {params[:revision_label] not in [nil, ""], + "the image names no commit in its #{@revision} label, so nothing says which commit it " <> + "was built from (scripts/headless_image.sh names one only for a build of an " <> + "unmodified tree)"}, + {params[:revision_label] in [nil, ""] or params[:revision_label] == commit, + "the image's #{@revision} is #{inspect(params[:revision_label])}, not --commit #{commit}"} + ] + |> Enum.reject(&elem(&1, 0)) + |> Enum.map(&elem(&1, 1)) + end + + defp present?(value), do: is_binary(value) and value != "" + + @doc """ + The predicate. `resources` are the hardening manifest's resources, `bases` the `KEY=VALUE` + pairs of the bases file. + """ + @spec predicate(map(), [map()], %{String.t() => String.t()}) :: map() + def predicate(params, resources, bases) do + source = %{ + "uri" => "git+#{params.repository}@#{params.ref}", + "digest" => %{"gitCommit" => params.commit} + } + + metadata = + if params[:invocation], do: %{"invocationId" => params.invocation}, else: %{} + + %{ + "buildDefinition" => %{ + "buildType" => @build_type, + "externalParameters" => %{ + "repository" => params.repository, + "ref" => params.ref, + "recipe" => @recipe, + "bases" => @bases + }, + "internalParameters" => %{"buildArgs" => bases}, + "resolvedDependencies" => + [source] ++ Enum.map(resources, &resource_dependency/1) ++ base_dependencies(bases) + }, + "runDetails" => %{ + "builder" => %{"id" => params.builder_id}, + "metadata" => metadata + } + } + end + + defp resource_dependency(%{"url" => url, "filename" => name, "validation" => v}) do + %{"name" => name, "uri" => url, "digest" => %{v["type"] => v["value"]}} + end + + # Each base as `oci://registry/image` with the digest its tag argument pins. + defp base_dependencies(bases) do + registry = bases["BASE_REGISTRY"] + + for {image_key, tag_key} <- [{"BASE_IMAGE", "BASE_TAG"}, {"BUILDER_IMAGE", "BUILDER_TAG"}], + image = bases[image_key], + [_tag, "sha256:" <> hex] <- [String.split(bases[tag_key] || "", "@", parts: 2)] do + %{"uri" => "oci://#{registry}/#{image}", "digest" => %{"sha256" => hex}} + end + end + + @doc "The resources of a hardening manifest." + @spec read_resources!(Path.t()) :: [map()] + def read_resources!(path) do + case YamlElixir.read_from_file(path) do + {:ok, %{"resources" => resources}} when is_list(resources) -> resources + other -> Mix.raise("#{path} has no resources list: #{inspect(other)}") + end + end + + @doc "The `KEY=VALUE` pairs of a bases file." + @spec read_bases!(Path.t()) :: %{String.t() => String.t()} + def read_bases!(path), do: path |> File.read!() |> Lint.env_pairs() +end diff --git a/lib/mix/tasks/trinity.image.slsa.ex b/lib/mix/tasks/trinity.image.slsa.ex new file mode 100644 index 0000000..572ea32 --- /dev/null +++ b/lib/mix/tasks/trinity.image.slsa.ex @@ -0,0 +1,193 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Mix.Tasks.Trinity.Image.Slsa do + @shortdoc "Fails when the claimed SLSA build level is above what the workflow's mechanism justifies (slice 131, AC4)" + + @moduledoc """ + Compares the SLSA build level this project claims for the headless image + (`ci/headless/slsa.yaml`) with the level the publishing workflow's mechanism can justify, read + from `.github/workflows/headless-image.yml`, and fails when the claim is the higher of the two. + + mix trinity.image.slsa [--claim PATH] [--workflow PATH] + + The levels are the SLSA build track's (`spec/build-track-basics.md` in slsa-framework/slsa): + + * **L1, provenance exists.** A job attaches provenance to the image: it runs + `mix trinity.image.provenance` and `scripts/image_supply_chain.sh attach`, and that script + attests the predicate as `slsaprovenance1`. + * **L2, a hosted platform generates and signs it.** L1, plus the same job runs on a + GitHub-hosted runner with `id-token: write` and `attestations: write`, asks GitHub for its + platform provenance with `actions/attest-build-provenance` pinned by commit and pushed to the + registry, and verifies it with `gh attestation verify`. GitHub documents its artifact + attestations as SLSA v1.0 Build Level 2. + * **L3, the signing secret is out of reach of the build steps.** L2, plus the provenance comes + from a job-level reusable workflow (`slsa-framework/slsa-github-generator`'s container + generator), and no job that runs steps of its own holds `id-token: write`. + + A mechanism in a file is not a mechanism that has worked, so a claim of L2 or more must also + cite at least one workflow run whose verification passed (`evidence:`). The claim file states + the reason for its level and why it is not the next; both must be present. + """ + + use Boundary, classify_to: Trinity + use Mix.Task + + @claim "ci/headless/slsa.yaml" + @workflow ".github/workflows/headless-image.yml" + @script "scripts/image_supply_chain.sh" + @attest ~r/\Aactions\/attest-build-provenance@[0-9a-f]{40}\z/ + @generator "slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@" + @hosted ~r/\A(ubuntu|windows|macos)-[0-9a-z.]+\z/ + @run_url ~r/\Ahttps:\/\/github\.com\/[^\/]+\/[^\/]+\/actions\/runs\/[0-9]+/ + + @impl Mix.Task + def run(argv) do + {opts, _, _} = OptionParser.parse(argv, strict: [claim: :string, workflow: :string]) + claim = read_yaml!(opts[:claim] || @claim) + workflow = read_yaml!(opts[:workflow] || @workflow) + script = File.read!(@script) + {level, reasons} = justified(workflow, script) + + Mix.shell().info( + "trinity.image.slsa: claimed Build L#{inspect(claim["build_level"])}; " <> + "the workflow's mechanism supports L#{level}" + ) + + Enum.each(reasons, &Mix.shell().info(" not L#{level + 1}: #{&1}")) + + case violations(claim, level) do + [] -> + Mix.shell().info("trinity.image.slsa: OK") + + found -> + Enum.each(found, &Mix.shell().error("FAIL AC4: #{&1}")) + Mix.raise("trinity.image.slsa: #{length(found)} violation(s)") + end + end + + defp read_yaml!(path) do + case YamlElixir.read_from_file(path) do + {:ok, %{} = map} -> map + other -> Mix.raise("#{path} is not a YAML map: #{inspect(other)}") + end + end + + @doc """ + The highest level the workflow's mechanism supports, and why it is not the next one: the + missing pieces of the next level, as sentences. + """ + @spec justified(map(), String.t()) :: {0..3, [String.t()]} + def justified(workflow, script) do + jobs = Map.get(workflow, "jobs") || %{} + publish = Enum.find_value(jobs, fn {_, job} -> if publishes?(job), do: job end) + + levels = [ + {1, l1_missing(publish, script)}, + {2, l2_missing(publish)}, + {3, l3_missing(jobs)} + ] + + Enum.reduce_while(levels, {0, []}, fn {n, missing}, {level, _} -> + if missing == [], do: {:cont, {n, []}}, else: {:halt, {level, missing}} + end) + end + + defp steps(nil), do: [] + defp steps(job), do: List.wrap(job["steps"]) + + defp runs?(job, text), do: Enum.any?(steps(job), &String.contains?(&1["run"] || "", text)) + + defp publishes?(job), do: runs?(job, "#{@script} attach") + + defp l1_missing(nil, _script), + do: ["no job runs `#{@script} attach`, so nothing attaches provenance to the image"] + + defp l1_missing(job, script) do + [ + {runs?(job, "mix trinity.image.provenance"), + "the publishing job does not generate provenance (`mix trinity.image.provenance`)"}, + {String.contains?(script, "--type slsaprovenance1"), + "#{@script} does not attest a `slsaprovenance1` predicate"} + ] + |> missing() + end + + defp l2_missing(nil), do: ["no publishing job"] + + defp l2_missing(job) do + permissions = job["permissions"] || %{} + + attest = + Enum.find(steps(job), fn s -> is_binary(s["uses"]) and Regex.match?(@attest, s["uses"]) end) + + [ + {is_binary(job["runs-on"]) and Regex.match?(@hosted, job["runs-on"]), + "the publishing job does not run on a GitHub-hosted runner"}, + {permissions["id-token"] == "write" and permissions["attestations"] == "write", + "the publishing job does not hold `id-token: write` and `attestations: write`"}, + {attest != nil, + "no step asks GitHub for platform provenance (`actions/attest-build-provenance` pinned by commit)"}, + {attest != nil and get_in(attest, ["with", "push-to-registry"]) in [true, "true"], + "the platform provenance is not pushed to the registry beside the image"}, + {runs?(job, "gh attestation verify"), + "no step verifies the platform provenance (`gh attestation verify`)"} + ] + |> missing() + end + + defp l3_missing(jobs) do + generator? = + Enum.any?(jobs, fn {_, job} -> + is_binary(job["uses"]) and String.starts_with?(job["uses"], @generator) + end) + + stepping_with_identity = + for {name, job} <- jobs, + steps(job) != [], + get_in(job, ["permissions", "id-token"]) == "write", + do: name + + [ + {generator?, + "the provenance is made in the same job as the build's own steps, not by an isolated " <> + "reusable workflow (#{String.trim_trailing(@generator, "@")})"}, + {stepping_with_identity == [], + "job(s) #{Enum.join(Enum.sort(stepping_with_identity), ", ")} run their own steps while " <> + "holding `id-token: write`, so the signing identity is in reach of those steps"} + ] + |> missing() + end + + defp missing(checks), do: checks |> Enum.reject(&elem(&1, 0)) |> Enum.map(&elem(&1, 1)) + + @doc "Why the claim cannot stand against a justified level; `[]` when it can." + @spec violations(map(), 0..3) :: [String.t()] + def violations(claim, justified) do + level = claim["build_level"] + evidence = List.wrap(claim["evidence"]) + + cond do + level not in 0..3 -> + ["build_level #{inspect(level)} is not a SLSA build level (0 to 3)"] + + level > justified -> + [ + "Build L#{level} is claimed, and the workflow's mechanism supports only L#{justified}" + ] + + true -> + [ + {level < 2 or (evidence != [] and Enum.all?(evidence, &run_url?/1)), + "Build L#{level} is claimed without a workflow run whose verification passed " <> + "(`evidence:` lists none)"}, + {present?(claim["reason"]), "the claim states no reason for its level (`reason:`)"}, + {level == 3 or present?(claim["not_next"]), + "the claim does not say why it is not L#{level + 1} (`not_next:`)"} + ] + |> missing() + end + end + + defp run_url?(url), do: is_binary(url) and Regex.match?(@run_url, url) + defp present?(text), do: is_binary(text) and String.trim(text) != "" +end diff --git a/lib/trinity/versions.ex b/lib/trinity/versions.ex index 64813bd..72f72eb 100644 --- a/lib/trinity/versions.ex +++ b/lib/trinity/versions.ex @@ -150,6 +150,36 @@ defmodule Trinity.Versions do from: {:file, "scripts/stig_scan.sh", "ssg_version=0.1.82"}, note: "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." + }, + %{ + name: "Image signing (cosign)", + pin: "**v3.1.3**", + lock: nil, + from: + {:file, ".github/workflows/headless-image.yml", + "COSIGN_SHA256: 4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71"}, + note: + "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." + }, + %{ + name: "CycloneDX validator (cyclonedx-cli)", + pin: "**0.33.1**", + lock: nil, + from: + {:file, ".github/workflows/headless-image.yml", + "cyclonedx/cyclonedx-cli:0.33.1@sha256:252c2e26f468c25fea1e63ecde1bc3198ad6e9dbb57f5ed3236bddcb2281b3a7"}, + note: + "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." + }, + %{ + name: "Test registry (registry:2)", + pin: "**sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373**", + lock: nil, + from: + {:file, "test/image_supply_chain_test.exs", + "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373"}, + note: + "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." } ] diff --git a/scripts/headless_image.sh b/scripts/headless_image.sh index f8cdee2..728b04e 100755 --- a/scripts/headless_image.sh +++ b/scripts/headless_image.sh @@ -109,7 +109,12 @@ build() { [ -n "$tag" ] || die "build needs an image tag" if [ -z "$dir" ]; then dir=$(mktemp -d) - trap 'rm -rf "$dir"' EXIT + # Expanded now, not at exit: `dir` is local to this function and is gone by the time the + # EXIT trap runs, so a trap that read it then failed under `set -u` ("dir: unbound + # variable"), exited 1 after a successful build and left the context behind (slice 131 + # NOTES, F-131-2). + # shellcheck disable=SC2064 + trap "rm -rf '$dir'" EXIT fi stage "$dir" @@ -119,9 +124,42 @@ build() { args+=(--build-arg "$line") done &2 + args+=(--label "org.opencontainers.image.revision=" --label "org.opencontainers.image.created=") + fi + docker build "${args[@]}" -t "$tag" -f "$dir/Dockerfile" "$dir" } +# KEY=VALUE per label: the manifest's OCI labels, and the source repository (its URL label). The +# manifest's mil.dso.ironbank.* labels are left to Iron Bank: on an image this project publishes +# they would read as a claim that it came from Iron Bank, which it did not. +labels() { + python3 -c ' +import yaml +labels = yaml.safe_load(open("ci/ironbank/hardening_manifest.yaml")).get("labels", {}) +for key, value in labels.items(): + if key.startswith("org.opencontainers.image."): + print(f"{key}={value}") +print("org.opencontainers.image.source=" + labels["org.opencontainers.image.url"]) +' +} + case "${1:-}" in stage) stage "${2:-}" ;; build) build "${2:-}" "${3:-}" ;; diff --git a/scripts/image_supply_chain.sh b/scripts/image_supply_chain.sh new file mode 100755 index 0000000..0c6ecd2 --- /dev/null +++ b/scripts/image_supply_chain.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Signs a published headless image and attaches its evidence (slice 131), so a consumer holding +# only the image and the project's public key can check all of it (docs/regulated/image-verify.md). +# +# scripts/image_supply_chain.sh attach REGISTRY/REPO@sha256:DIGEST --key KEYREF \ +# --provenance provenance.json --sbom sbom.cdx.json --aibom ai-bom.cdx.json +# +# Four statements, each signed by the held key and stored in the registry beside the image as an +# OCI referrer of its digest: +# +# the signature cosign sign https://sigstore.dev/cosign/sign/v1 +# build provenance cosign attest --type slsaprovenance1 https://slsa.dev/provenance/v1 +# the SBOM cosign attest --type cyclonedx https://cyclonedx.org/bom +# the AI-BOM cosign attest --type +# +# The key is a held key, never a transparency log (slice 131 NOTES, D-131-1): KEYREF is anything +# cosign takes for --key (a file, env://VAR, a KMS URI, pkcs11:), and its password, if any, comes +# from COSIGN_PASSWORD. Nothing is uploaded to a public log, so a consumer with no network verifies +# exactly what a connected one does. +# +# The bills are inputs, generated first and checked by their own tasks: `mix trinity.image.provenance`, +# `mix trinity.sbom`, `mix trinity.image.aibom`. The reference must carry a digest, because a tag can +# move between the push and the signature. +# +# COSIGN names the cosign program (default `cosign`). IMAGE_REGISTRY_INSECURE=1 lets cosign talk +# plain HTTP to a registry on the loopback, which is what the tests use; never set it for a real one. +set -euo pipefail + +AIBOM_TYPE="https://github.com/ScriptKittyOS/Trinity/blob/main/docs/regulated/image-verify.md#ai-bom" +cosign_bin="${COSIGN:-cosign}" + +die() { + echo "image_supply_chain: $*" >&2 + exit 1 +} + +attach() { + local ref="${1:-}" key="" provenance="" sbom="" aibom="" + shift || true + while [ $# -gt 0 ]; do + case "$1" in + --key) key=$2 ;; + --provenance) provenance=$2 ;; + --sbom) sbom=$2 ;; + --aibom) aibom=$2 ;; + *) die "unknown argument $1" ;; + esac + shift 2 + done + + case "$ref" in + *@sha256:*) ;; + *) die "$ref is not pinned by digest (REGISTRY/REPO@sha256:HEX)" ;; + esac + [ -n "$key" ] || die "--key is required" + for f in "$provenance" "$sbom" "$aibom"; do + [ -n "$f" ] && [ -f "$f" ] || die "--provenance, --sbom and --aibom must each name a file" + done + command -v "$cosign_bin" >/dev/null || die "cosign not found: $cosign_bin" + + # A held key and no log: the signing config would otherwise name Sigstore's public services. + local common=(--yes --key "$key" --use-signing-config=false --tlog-upload=false) + if [ "${IMAGE_REGISTRY_INSECURE:-}" = "1" ]; then + common+=(--allow-insecure-registry --allow-http-registry) + fi + + "$cosign_bin" sign "${common[@]}" "$ref" + "$cosign_bin" attest "${common[@]}" --type slsaprovenance1 --predicate "$provenance" "$ref" + "$cosign_bin" attest "${common[@]}" --type cyclonedx --predicate "$sbom" "$ref" + "$cosign_bin" attest "${common[@]}" --type "$AIBOM_TYPE" --predicate "$aibom" "$ref" + echo "image_supply_chain: signed $ref and attached provenance, SBOM and AI-BOM" >&2 +} + +case "${1:-}" in + attach) + shift + attach "$@" + ;; + aibom-type) echo "$AIBOM_TYPE" ;; + *) die "usage: $0 attach REGISTRY/REPO@sha256:DIGEST --key KEYREF --provenance F --sbom F --aibom F" ;; +esac diff --git a/test/image_supply_chain_test.exs b/test/image_supply_chain_test.exs new file mode 100644 index 0000000..f8c4be6 --- /dev/null +++ b/test/image_supply_chain_test.exs @@ -0,0 +1,486 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule ImageSupplyChainDocTest do + @moduledoc """ + Slice 131, AC6 and AC4, the parts that need no tools: the verification block is where the + tests and the publishing job look for it, the job runs it, and the page states the level + `ci/headless/slsa.yaml` claims. `ImageSupplyChainTest` below runs the block itself. + """ + use ExUnit.Case, async: true + + @doc_path "docs/regulated/image-verify.md" + @workflow ".github/workflows/headless-image.yml" + @script "scripts/image_supply_chain.sh" + + @doc "The verification block of the page: the text of the bash fence between the two markers." + @spec verify_block(String.t()) :: String.t() + def verify_block(text) do + [_, inside] = String.split(text, "", parts: 2) + [inside, _] = String.split(inside, "", parts: 2) + [_, fence] = Regex.run(~r/\A\s*```bash\n(.*)```\s*\z/s, inside) + fence + end + + describe "the documented sequence, without the tools" do + test "the page carries exactly one block between the markers, a bash fence" do + text = File.read!(@doc_path) + assert length(String.split(text, "")) == 2 + assert length(String.split(text, "")) == 2 + block = verify_block(text) + assert block =~ ~s("$COSIGN" verify ) + assert block =~ "statements slsaprovenance1" + end + + test "the publishing job extracts the same block from the same page and runs it" do + workflow = File.read!(@workflow) + assert workflow =~ "" + assert workflow =~ "" + assert workflow =~ @doc_path + end + + test "the block and the producer script name the same AI-BOM predicate type" do + {type, 0} = System.cmd("bash", [@script, "aibom-type"]) + assert verify_block(File.read!(@doc_path)) =~ ~s(AIBOM_TYPE="#{String.trim(type)}") + end + + test "the page states the SLSA level ci/headless/slsa.yaml claims, and no other" do + {:ok, %{"build_level" => level}} = YamlElixir.read_from_file("ci/headless/slsa.yaml") + text = File.read!(@doc_path) + + assert text =~ "**Claimed: SLSA Build L#{level}**" + claimed = Regex.scan(~r/Claimed: SLSA Build L(\d)/, text) |> Enum.map(&List.last/1) + assert claimed == [Integer.to_string(level)] + end + end +end + +defmodule ImageSupplyChainTest do + @moduledoc """ + Slice 131: the published image's signature, provenance, SBOM and AI-BOM, checked the way a + stranger checks them, by running the exact sequence `docs/regulated/image-verify.md` documents + (AC6), extracted from the page at run time. + + The `:image_signing` cases (AC1, AC2, AC3, AC6, AC7's attestation) push small images to a + `registry:2` container on the loopback, sign and attest them with `scripts/image_supply_chain.sh` + and the real cosign (`TRINITY_COSIGN`), save each as an OCI layout with `cosign save`, stop + nothing else, and run the documented block against the layout. The genuine case passes, and + each tampering fails at its own step with its own exit status, which is asserted, along with the + `FAIL ` line the block printed. Keys are made by the test, in its own directory. + + Every case here is tagged `:image_signing` (test/test_helper.exs). The parts that need no tools + are `ImageSupplyChainDocTest` above. + """ + use ExUnit.Case, async: false + + @moduletag :image_signing + @moduletag timeout: 600_000 + + alias Mix.Tasks.Trinity.Image.{Aibom, Provenance} + + @doc_path "docs/regulated/image-verify.md" + @script "scripts/image_supply_chain.sh" + @registry_image "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373" + @repository "https://github.com/ScriptKittyOS/Trinity" + @builder "https://github.com/ScriptKittyOS/Trinity/.github/workflows/headless-image.yml@refs/heads/main" + @commit String.duplicate("c1", 20) + @other_commit String.duplicate("d2", 20) + # Not empty: an empty value in a port's environment unsets the variable, and cosign then waits + # for a password on the terminal (observed: generate-key-pair never returned). + @password "s131-test-only" + + setup_all do + cosign = System.fetch_env!("TRINITY_COSIGN") + + assert File.regular?(cosign), + "TRINITY_COSIGN=#{cosign} names no file; set it to the cosign program or unset it" + + for tool <- ~w(docker jq sha256sum) do + assert System.find_executable(tool), "#{tool} is not on PATH" + end + + dir = Path.join(System.tmp_dir!(), "s131-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + name = "s131-registry-#{System.unique_integer([:positive])}" + + {_, 0} = + System.cmd("docker", ~w(run -d --rm --name #{name} -p 127.0.0.1::5000 #{@registry_image})) + + {port_line, 0} = System.cmd("docker", ["port", name, "5000/tcp"]) + [port] = Regex.run(~r/:(\d+)\s*\z/, String.trim(port_line), capture: :all_but_first) + registry = "127.0.0.1:#{port}" + + on_exit(fn -> + System.cmd("docker", ["rm", "-f", name], stderr_to_stdout: true) + {tags, _} = System.cmd("docker", ["images", "--format", "{{.Repository}}:{{.Tag}}"]) + + for t <- String.split(tags, "\n", trim: true), String.starts_with?(t, registry <> "/") do + System.cmd("docker", ["rmi", "-f", t], stderr_to_stdout: true) + end + + File.rm_rf!(dir) + end) + + key = keypair!(cosign, dir, "project") + other = keypair!(cosign, dir, "other") + + %{cosign: cosign, dir: dir, registry: registry, key: key, other_key: other} + end + + setup ctx do + Map.put(ctx, :case_dir, mkdir!(ctx.dir, "case-#{System.unique_integer([:positive])}")) + end + + test "AC1 to AC3, AC6, AC7: the genuine image passes every step", ctx do + ref = push!(ctx, "genuine", @commit) + bills = bills!(ctx) + attach!(ctx, ref, bills) + layout = save!(ctx, ref) + + {out, 0} = verify(ctx, layout, reference_sbom: bills.sbom) + + for step <- ~w(layout signature commit builder sbom sbom-match aibom) do + assert out =~ ~r/^OK #{step}: /m, "no OK line for #{step} in:\n#{out}" + end + + assert out =~ "the image and its provenance both name #{@commit}" + end + + test "AC1 red: an image this tree did not sign (main has no signing) is refused", ctx do + ref = push!(ctx, "unsigned", @commit) + assert_fails(verify(ctx, save!(ctx, ref)), "signature", 11) + end + + test "AC1 red: another image pushed under the signed image's tag is refused", ctx do + ref = push!(ctx, "moved-tag", @commit, tag: "release") + attach!(ctx, ref, bills!(ctx)) + _replaced = push!(ctx, "moved-tag-replacement", @commit, repo: "moved-tag", tag: "release") + + layout = save!(ctx, "#{ctx.registry}/s131/moved-tag:release") + assert_fails(verify(ctx, layout), "signature", 11) + end + + test "AC1 red: the genuine signatures moved onto another image are refused", ctx do + signed = push!(ctx, "donor", @commit) + attach!(ctx, signed, bills!(ctx)) + donor = save!(ctx, signed) + target = save!(ctx, push!(ctx, "recipient", @commit)) + + transplant!(donor, target) + assert_fails(verify(ctx, target), "signature", 11) + end + + test "AC1 red: a layer altered in the layout is refused (cosign alone accepts it)", ctx do + ref = push!(ctx, "altered-layer", @commit) + attach!(ctx, ref, bills!(ctx)) + layout = save!(ctx, ref) + + layer = layer_blob(layout) + bytes = File.read!(layer) + <> = bytes + File.write!(layer, <>) + + {_, cosign_status} = cosign_verify(ctx, layout, ctx.key.pub) + assert cosign_status == 0, "cosign verify now checks layers; the layout step is still needed" + + assert_fails(verify(ctx, layout), "layout", 10) + end + + test "AC1 red: a signature by another key is refused", ctx do + ref = push!(ctx, "other-key", @commit) + attach!(ctx, ref, bills!(ctx), key: ctx.other_key) + assert_fails(verify(ctx, save!(ctx, ref)), "signature", 11) + end + + test "AC2 red: a provenance whose commit does not match the image is refused", ctx do + ref = push!(ctx, "wrong-commit", @commit) + attach!(ctx, ref, bills!(ctx, commit: @other_commit)) + + {out, status} = verify(ctx, save!(ctx, ref)) + assert_fails({out, status}, "commit", 13) + assert out =~ "the provenance names another commit than #{@commit}" + end + + test "AC2 red: an image built from another commit than the one expected is refused", ctx do + ref = push!(ctx, "other-expected", @commit) + attach!(ctx, ref, bills!(ctx)) + + {out, status} = verify(ctx, save!(ctx, ref), commit: @other_commit) + assert_fails({out, status}, "commit", 13) + assert out =~ "the image says it was built from '#{@commit}', not #{@other_commit}" + end + + test "AC2 red: an image with no provenance is refused", ctx do + ref = push!(ctx, "no-provenance", @commit) + sign_only!(ctx, ref) + assert_fails(verify(ctx, save!(ctx, ref)), "provenance", 12) + end + + test "AC2 red: a provenance from another builder is refused", ctx do + ref = push!(ctx, "other-builder", @commit) + attach!(ctx, ref, bills!(ctx, builder: "https://example.invalid/builder")) + assert_fails(verify(ctx, save!(ctx, ref)), "builder", 14) + end + + test "AC3 red: an SBOM that drifted from the tree's bill for the commit is refused", ctx do + ref = push!(ctx, "drifted-sbom", @commit) + bills = bills!(ctx) + drifted = Path.join(ctx.case_dir, "drifted.cdx.json") + + bills.sbom + |> File.read!() + |> Jason.decode!() + |> update_in(["components", Access.at(0), "version"], fn _ -> "9.9.9" end) + |> then(&File.write!(drifted, Jason.encode!(&1))) + + attach!(ctx, ref, %{bills | sbom: drifted}) + + {out, status} = verify(ctx, save!(ctx, ref), reference_sbom: bills.sbom) + assert_fails({out, status}, "sbom-match", 16) + end + + test "AC7: an AI-BOM whose dataset list was removed is refused", ctx do + ref = push!(ctx, "no-datasets", @commit) + bills = bills!(ctx) + stripped = Path.join(ctx.case_dir, "stripped.cdx.json") + + bills.aibom + |> File.read!() + |> Jason.decode!() + |> update_in(["components"], fn cs -> + Enum.map(cs, fn + %{"type" => "machine-learning-model"} = c -> + update_in(c, ["modelCard", "modelParameters"], &Map.delete(&1, "datasets")) + + c -> + c + end) + end) + |> then(&File.write!(stripped, Jason.encode!(&1))) + + attach!(ctx, ref, %{bills | aibom: stripped}) + assert_fails(verify(ctx, save!(ctx, ref)), "aibom", 17) + end + + # -- helpers --------------------------------------------------------------------------------- + + defp mkdir!(parent, name) do + path = Path.join(parent, name) + File.mkdir_p!(path) + path + end + + defp keypair!(cosign, dir, name) do + {out, status} = + System.cmd(cosign, ["generate-key-pair", "--output-key-prefix", Path.join(dir, name)], + env: [{"COSIGN_PASSWORD", @password}], + stderr_to_stdout: true + ) + + assert status == 0, out + %{key: Path.join(dir, name <> ".key"), pub: Path.join(dir, name <> ".pub")} + end + + # A two-file image FROM scratch, labelled with its commit the way headless_image.sh labels one. + defp push!(ctx, name, commit, opts \\ []) do + context = mkdir!(ctx.dir, "img-#{name}-#{System.unique_integer([:positive])}") + File.write!(Path.join(context, "Dockerfile"), "FROM scratch\nCOPY payload.txt /payload.txt\n") + File.write!(Path.join(context, "payload.txt"), "#{name} #{System.unique_integer()}\n") + repo = "#{ctx.registry}/s131/#{opts[:repo] || name}" + tag = "#{repo}:#{opts[:tag] || "1"}" + + {out, status} = + System.cmd( + "docker", + ["build", "-q", "--label", "org.opencontainers.image.revision=#{commit}"] ++ + ["--label", "org.opencontainers.image.source=#{@repository}", "-t", tag, context], + stderr_to_stdout: true + ) + + assert status == 0, out + {out, status} = System.cmd("docker", ["push", tag], stderr_to_stdout: true) + assert status == 0, out + [_, digest] = Regex.run(~r/digest: (sha256:[0-9a-f]{64})/, out) + "#{repo}@#{digest}" + end + + defp bills!(ctx, opts \\ []) do + dir = mkdir!(ctx.case_dir, "bills-#{System.unique_integer([:positive])}") + + params = %{ + commit: opts[:commit] || @commit, + repository: @repository, + builder_id: opts[:builder] || @builder, + ref: "refs/heads/main", + invocation: nil + } + + provenance = + Provenance.predicate( + params, + Provenance.read_resources!("ci/ironbank/hardening_manifest.yaml"), + Provenance.read_bases!("ci/headless/bases.env") + ) + + sbom = %{ + "bomFormat" => "CycloneDX", + "specVersion" => "1.6", + "version" => 1, + "components" => [ + %{ + "type" => "library", + "group" => "hexpm", + "name" => "jason", + "version" => "1.4.5", + "purl" => "pkg:hex/jason@1.4.5" + }, + %{ + "type" => "library", + "group" => "hexpm", + "name" => "jcs", + "version" => "0.2.0", + "purl" => "pkg:hex/jcs@0.2.0" + } + ] + } + + aibom = Aibom.bom(Aibom.read_models!("test/support/fixtures/supply_chain/models.yaml")) + + for {name, doc} <- [provenance: provenance, sbom: sbom, aibom: aibom], into: %{} do + path = Path.join(dir, "#{name}.json") + File.write!(path, Jason.encode!(doc)) + {name, path} + end + end + + defp attach!(ctx, ref, bills, opts \\ []) do + key = (opts[:key] || ctx.key).key + + {out, status} = + System.cmd( + "bash", + [@script, "attach", ref, "--key", key] ++ + ["--provenance", bills.provenance, "--sbom", bills.sbom, "--aibom", bills.aibom], + env: cosign_env(ctx), + stderr_to_stdout: true + ) + + assert status == 0, out + end + + defp sign_only!(ctx, ref) do + {out, status} = + System.cmd( + ctx.cosign, + ~w(sign --yes --key #{ctx.key.key} --use-signing-config=false --tlog-upload=false + --allow-insecure-registry --allow-http-registry #{ref}), + env: [{"COSIGN_PASSWORD", @password}], + stderr_to_stdout: true + ) + + assert status == 0, out + end + + defp cosign_env(ctx), + do: [{"COSIGN", ctx.cosign}, {"COSIGN_PASSWORD", @password}, {"IMAGE_REGISTRY_INSECURE", "1"}] + + defp save!(ctx, ref) do + layout = Path.join(ctx.case_dir, "layout-#{System.unique_integer([:positive])}") + + {out, status} = + System.cmd( + ctx.cosign, + ["save", "--allow-insecure-registry", "--allow-http-registry", "--dir", layout, ref], + stderr_to_stdout: true + ) + + assert status == 0, out + layout + end + + defp cosign_verify(ctx, layout, pub) do + System.cmd( + ctx.cosign, + [ + "verify", + "--key", + pub, + "--offline", + "--insecure-ignore-tlog=true", + "--local-image", + layout + ], + stderr_to_stdout: true + ) + end + + # Runs the page's block, exactly as extracted, in bash, with the inputs as the page names them. + defp verify(ctx, layout, opts \\ []) do + script = Path.join(ctx.case_dir, "verify-#{System.unique_integer([:positive])}.sh") + File.write!(script, ImageSupplyChainDocTest.verify_block(File.read!(@doc_path))) + + env = + [ + {"LAYOUT", layout}, + {"PUBKEY", ctx.key.pub}, + {"COMMIT", opts[:commit] || @commit}, + {"REPOSITORY", @repository}, + {"BUILDER", @builder}, + {"COSIGN", ctx.cosign}, + {"REFERENCE_SBOM", opts[:reference_sbom]} + ] + + System.cmd("bash", [script], env: env, stderr_to_stdout: true, cd: ctx.case_dir) + end + + defp assert_fails({out, status}, step, expected) do + assert status == expected, + "expected exit #{expected} at #{step}, got #{status}:\n#{out}" + + assert out =~ ~r/^FAIL #{step}: /m, "no FAIL #{step} line in:\n#{out}" + end + + # The image manifest's first layer, as a blob path of the layout. + defp layer_blob(layout) do + [entry] = + layout |> Path.join("index.json") |> File.read!() |> Jason.decode!() |> Map.get("manifests") + + manifest = blob_json(layout, entry["digest"]) + [%{"digest" => digest} | _] = manifest["layers"] + blob_path(layout, digest) + end + + # Copies every referrer of the donor's image into the target layout, its subject rewritten to + # the target's image: the genuine signatures, now claiming another image (NOTES, M4). + defp transplant!(donor, target) do + [%{"digest" => donor_digest}] = index(donor) + [%{"digest" => target_digest}] = index(target) + + for file <- File.ls!(Path.join([donor, "blobs", "sha256"])), + json = decode(File.read!(Path.join([donor, "blobs", "sha256", file]))), + match?(%{"subject" => %{"digest" => ^donor_digest}}, json) do + moved = Jason.encode!(put_in(json, ["subject", "digest"], target_digest)) + hex = :crypto.hash(:sha256, moved) |> Base.encode16(case: :lower) + File.write!(blob_path(target, "sha256:" <> hex), moved) + + for %{"digest" => d} <- json["layers"] do + File.cp!(blob_path(donor, d), blob_path(target, d)) + end + end + end + + defp index(layout), + do: + layout |> Path.join("index.json") |> File.read!() |> Jason.decode!() |> Map.get("manifests") + + defp blob_path(layout, "sha256:" <> hex), do: Path.join([layout, "blobs", "sha256", hex]) + + defp blob_json(layout, digest), + do: layout |> blob_path(digest) |> File.read!() |> Jason.decode!() + + defp decode(bytes) do + case Jason.decode(bytes) do + {:ok, %{} = map} -> map + _ -> nil + end + end +end diff --git a/test/mix/tasks/trinity_image_aibom_test.exs b/test/mix/tasks/trinity_image_aibom_test.exs new file mode 100644 index 0000000..d70666e --- /dev/null +++ b/test/mix/tasks/trinity_image_aibom_test.exs @@ -0,0 +1,334 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Mix.Tasks.Trinity.Image.AibomTest do + @moduledoc """ + Slice 131, AC7: the AI-BOM generator and the check that holds its profile, over a fixture + shaped like slice 133's static model and slice 134's Tier 3 model + (`test/support/fixtures/supply_chain/models.yaml`), and over the image's own models file. + """ + use ExUnit.Case, async: true + + alias Mix.Tasks.Trinity.Image.Aibom + + @fixture "test/support/fixtures/supply_chain/models.yaml" + @image_models "ci/headless/ai_models.yaml" + @static "sentence-transformers/static-retrieval-mrl-en-v1" + @weights "5990c1104963d8e2402854c80b9a8f8e3a490085c037f25ced63642f4578c520" + + defp models, do: Aibom.read_models!(@fixture) + defp bom(models \\ models()), do: Aibom.bom(models, source: @fixture) + defp static(models), do: Enum.find(models, &(&1["space"]["model_id"] == @static)) + + defp update_static(fun) do + Enum.map(models(), fn m -> if m["space"]["model_id"] == @static, do: fun.(m), else: m end) + end + + defp component(bom, type, name), + do: Enum.find(bom["components"], &(&1["type"] == type and &1["name"] == name)) + + defp prop(component, name), + do: Enum.find_value(component["properties"], &(&1["name"] == name && &1["value"])) + + describe "the fixture" do + test "generates a CycloneDX 1.6 ML-BOM the check accepts" do + bom = bom() + assert Aibom.check(bom) == [] + assert bom["bomFormat"] == "CycloneDX" and bom["specVersion"] == "1.6" + + types = Enum.frequencies_by(bom["components"], & &1["type"]) + assert types == %{"machine-learning-model" => 2, "data" => 14} + end + + test "the shipped model carries the weights SHA-256, its licence and the space" do + model = component(bom(), "machine-learning-model", "static-retrieval-mrl-en-v1") + + assert model["group"] == "sentence-transformers" + assert model["version"] == "f60985c706f192d45d218078e49e5a8b6f15283a" + assert model["scope"] == "required" + assert model["hashes"] == [%{"alg" => "SHA-256", "content" => @weights}] + assert model["licenses"] == [%{"license" => %{"id" => "Apache-2.0"}}] + assert prop(model, "trinity:delivery") == "shipped" + assert prop(model, "trinity:legal-review") == "pending" + + manifest = prop(model, "trinity:space:manifest") + assert Jcs.encode(Jason.decode!(manifest)) == manifest + assert Jason.decode!(manifest)["weights_digest"] == @weights + assert prop(model, "trinity:space:id") == Aibom.space_id(static(models())["space"]) + + assert prop(model, "trinity:space:id") == + :crypto.hash(:sha256, manifest) |> Base.encode16(case: :lower) + end + + test "every training dataset is a data component, MS MARCO with its terms quoted" do + bom = bom() + model = component(bom, "machine-learning-model", "static-retrieval-mrl-en-v1") + refs = Enum.map(model["modelCard"]["modelParameters"]["datasets"], & &1["ref"]) + assert length(refs) == 13 + + by_ref = Map.new(bom["components"], &{&1["bom-ref"], &1}) + assert Enum.all?(refs, &(by_ref[&1]["type"] == "data")) + + marco = + component( + bom, + "data", + "sentence-transformers/msmarco-co-condenser-margin-mse-sym-mnrl-mean-v1" + ) + + [%{"license" => licence}] = marco["licenses"] + assert licence["name"] == "MS MARCO terms" + refute Map.has_key?(licence, "id") + assert licence["text"]["content"] =~ "non-commercial research purposes only" + end + + test "a consideration names the risk, with the legal review's status as its mitigation" do + model = component(bom(), "machine-learning-model", "static-retrieval-mrl-en-v1") + [risk] = model["modelCard"]["considerations"]["ethicalConsiderations"] + + assert risk["name"] =~ "MS MARCO" + + assert risk["mitigationStrategy"] =~ + "Legal review of the licence and the training-data terms: pending" + end + + test "the operator-pulled model is recorded as operator-accepted, never as shipped" do + model = component(bom(), "machine-learning-model", "Qwen3-Embedding-0.6B") + + assert prop(model, "trinity:delivery") == "operator-accepted" + assert model["scope"] == "optional" + assert prop(model, "trinity:legal-review") == "operator" + end + + test "the same models give the same bill, serial number included" do + assert bom() == bom() + assert bom()["serialNumber"] =~ ~r/\Aurn:uuid:[0-9a-f-]{36}\z/ + end + end + + describe "AC7 red: the check refuses" do + test "a model whose dataset list was removed (from the models file)" do + models = update_static(&Map.delete(&1, "datasets")) + + assert Aibom.check(bom(models)) == [ + "model sentence-transformers/static-retrieval-mrl-en-v1: no training dataset is " <> + "listed (modelCard.modelParameters.datasets)" + ] + end + + test "a model whose dataset list was removed (from the bill itself)" do + bom = bom() + + stripped = + update_in(bom, ["components"], fn cs -> + Enum.map(cs, fn + %{"name" => "static-retrieval-mrl-en-v1"} = c -> + update_in(c, ["modelCard", "modelParameters"], &Map.delete(&1, "datasets")) + + c -> + c + end) + end) + + assert [msg] = Aibom.check(stripped) + assert msg =~ "no training dataset is listed" + end + + test "an empty dataset list" do + assert [msg] = Aibom.check(bom(update_static(&Map.put(&1, "datasets", [])))) + assert msg =~ "no training dataset is listed" + end + + test "a dataset with no licence and no quoted terms" do + models = + update_static(fn m -> + update_in(m, ["datasets"], fn [first | rest] -> + [Map.delete(first, "licence") | rest] + end) + end) + + assert [msg] = Aibom.check(bom(models)) + assert msg =~ "has no licence: an SPDX id, or a name with the terms quoted" + end + + test "terms named but not quoted" do + models = + update_static(fn m -> + update_in(m, ["datasets"], fn [first | rest] -> + [put_in(first, ["licence"], %{"name" => "MS MARCO terms"}) | rest] + end) + end) + + assert [msg] = Aibom.check(bom(models)) + assert msg =~ "has no licence" + end + + test "an unrecorded dataset licence once the legal review has passed" do + models = update_static(&put_in(&1, ["legal_review", "status"], "passed")) + found = Aibom.check(bom(models)) + + assert length(found) == 11 + assert Enum.all?(found, &(&1 =~ "licence is unrecorded, but the legal review passed")) + end + + test "a dataset reference that resolves to nothing" do + bom = bom() + + broken = + update_in(bom, ["components"], fn cs -> + Enum.reject(cs, &(&1["name"] == "sentence-transformers/squad")) + end) + + assert [msg] = Aibom.check(broken) + assert msg =~ "resolves to no data component" + end + + test "a shipped model with no weights hash, or a hash that is not the space's digest" do + no_hash = update_static(&Map.delete(&1, "weights")) + assert [msg] = Aibom.check(bom(no_hash)) + assert msg =~ "no SHA-256 of the weights the image carries" + + other = String.duplicate("ab", 32) + wrong = update_static(&put_in(&1, ["weights", "sha256"], other)) + assert [msg] = Aibom.check(bom(wrong)) + assert msg =~ "is not the space's weights_digest" + end + + test "an operator-accepted model claimed as required" do + bom = bom() + + claimed = + update_in(bom, ["components"], fn cs -> + Enum.map(cs, fn + %{"name" => "Qwen3-Embedding-0.6B"} = c -> Map.put(c, "scope", "required") + c -> c + end) + end) + + assert [msg] = Aibom.check(claimed) + assert msg =~ "an operator-accepted model is never shipped" + end + + test "a delivery or a review status outside the vocabulary" do + assert [msg] = Aibom.check(bom(update_static(&Map.put(&1, "delivery", "bundled")))) + assert msg =~ ~s(delivery "bundled" is not one of) + + assert [msg] = + Aibom.check(bom(update_static(&put_in(&1, ["legal_review", "status"], "ok")))) + + assert msg =~ ~s(legal review status "ok") + end + + test "a model with no risk named" do + assert [msg] = Aibom.check(bom(update_static(&Map.delete(&1, "risks")))) + assert msg =~ "no consideration names a risk" + end + + test "a model with no licence" do + assert [msg] = Aibom.check(bom(update_static(&Map.delete(&1, "licence")))) + assert msg =~ ": no licence" + end + + test "a space manifest edited after its ID was computed" do + bom = bom() + + edited = + update_in(bom, ["components"], fn cs -> + Enum.map(cs, fn + %{"name" => "static-retrieval-mrl-en-v1"} = c -> + update_in(c, ["properties"], fn ps -> + Enum.map(ps, fn + %{"name" => "trinity:space:manifest", "value" => v} = p -> + %{p | "value" => String.replace(v, ~s("dim":256), ~s("dim":1024))} + + p -> + p + end) + end) + + c -> + c + end) + end) + + assert [msg] = Aibom.check(edited) + assert msg =~ "trinity:space:id is not the SHA-256 of the space manifest" + end + + test "a space missing one of slice 133's fourteen fields" do + models = update_static(&update_in(&1, ["space"], fn s -> Map.delete(s, "num_ctx") end)) + assert [msg] = Aibom.check(bom(models)) + assert msg =~ "not slice 133's fourteen" + end + + test "a document that is not CycloneDX 1.6" do + found = Aibom.check(%{"bomFormat" => "SPDX", "components" => []}) + assert "bomFormat is not CycloneDX" in found + assert "specVersion is not 1.6" in found + assert "serialNumber is not a urn:uuid" in found + end + end + + describe "the image's own models file" do + test "generates a bill the check accepts" do + models = Aibom.read_models!(@image_models) + bom = Aibom.bom(models, source: @image_models) + assert Aibom.check(bom) == [] + end + + test "D7: no model is shipped before its legal review has passed" do + assert Aibom.unreviewed_shipped(Aibom.read_models!(@image_models)) == [] + end + + test "D7's check finds a shipped model whose review is pending (planted)" do + assert Aibom.unreviewed_shipped(models()) == [@static] + end + end + + describe "the task" do + @describetag :tmp_dir + + test "writes a bill that --check then accepts", %{tmp_dir: dir} do + out = Path.join(dir, "ai-bom.cdx.json") + + Mix.Tasks.Trinity.Image.Aibom.run([ + "--models", + @fixture, + "--out", + out, + "--image", + "trinity-headless", + "--digest", + "sha256:" <> String.duplicate("0", 64) + ]) + + bom = out |> File.read!() |> Jason.decode!() + + assert bom["metadata"]["component"]["hashes"] == [ + %{"alg" => "SHA-256", "content" => String.duplicate("0", 64)} + ] + + Mix.Tasks.Trinity.Image.Aibom.run(["--check", out]) + end + + test "--check refuses a bill without a dataset list", %{tmp_dir: dir} do + path = Path.join(dir, "bad.cdx.json") + File.write!(path, Jason.encode!(bom(update_static(&Map.delete(&1, "datasets"))))) + + assert_raise Mix.Error, ~r/1 violation/, fn -> + Mix.Tasks.Trinity.Image.Aibom.run(["--check", path]) + end + end + + test "refuses to write a bill that fails the check", %{tmp_dir: dir} do + models = Path.join(dir, "models.yaml") + out = Path.join(dir, "ai-bom.cdx.json") + File.write!(models, File.read!(@fixture) |> String.replace("risks:", "unused_risks:")) + + assert_raise Mix.Error, ~r/violation/, fn -> + Mix.Tasks.Trinity.Image.Aibom.run(["--models", models, "--out", out]) + end + + refute File.exists?(out) + end + end +end diff --git a/test/mix/tasks/trinity_image_provenance_test.exs b/test/mix/tasks/trinity_image_provenance_test.exs new file mode 100644 index 0000000..581891c --- /dev/null +++ b/test/mix/tasks/trinity_image_provenance_test.exs @@ -0,0 +1,129 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Mix.Tasks.Trinity.Image.ProvenanceTest do + @moduledoc """ + Slice 131, AC2, the producer's half: the provenance names the builder, the repository and the + commit, lists the build's other inputs by digest, and is refused for an image that does not + name the same commit. The consumer's half, a provenance whose commit does not match the image, + is the verification sequence's (`test/image_supply_chain_test.exs`). + """ + use ExUnit.Case, async: true + + alias Mix.Tasks.Trinity.Image.Provenance + + @commit String.duplicate("a1", 20) + @other "0123456789abcdef0123456789abcdef01234567" + @repo "https://github.com/ScriptKittyOS/Trinity" + @builder "https://github.com/ScriptKittyOS/Trinity/.github/workflows/headless-image.yml@refs/heads/main" + + defp params(overrides \\ %{}) do + Map.merge( + %{ + commit: @commit, + repository: @repo, + builder_id: @builder, + ref: "refs/heads/main", + invocation: "https://github.com/ScriptKittyOS/Trinity/actions/runs/1/attempts/1", + revision_label: @commit + }, + overrides + ) + end + + defp tree_predicate do + Provenance.predicate( + params(), + Provenance.read_resources!("ci/ironbank/hardening_manifest.yaml"), + Provenance.read_bases!("ci/headless/bases.env") + ) + end + + describe "the predicate" do + test "names the builder, the source repository and the exact source commit" do + p = tree_predicate() + + assert p["runDetails"]["builder"]["id"] == @builder + assert p["buildDefinition"]["externalParameters"]["repository"] == @repo + assert p["buildDefinition"]["buildType"] == Provenance.build_type() + + [source | _] = p["buildDefinition"]["resolvedDependencies"] + + assert source == %{ + "uri" => "git+#{@repo}@refs/heads/main", + "digest" => %{"gitCommit" => @commit} + } + + assert p["runDetails"]["metadata"]["invocationId"] =~ "/actions/runs/1/" + end + + test "lists every resource of the hardening manifest, with its digest" do + resources = Provenance.read_resources!("ci/ironbank/hardening_manifest.yaml") + deps = tree_predicate()["buildDefinition"]["resolvedDependencies"] + + assert resources != [] + + for r <- resources do + assert %{ + "name" => r["filename"], + "uri" => r["url"], + "digest" => %{r["validation"]["type"] => r["validation"]["value"]} + } in deps + end + end + + test "lists both bases by the digest bases.env pins" do + deps = tree_predicate()["buildDefinition"]["resolvedDependencies"] + bases = Enum.filter(deps, &String.starts_with?(&1["uri"], "oci://")) + + assert Enum.map(bases, & &1["uri"]) == [ + "oci://registry.access.redhat.com/ubi9/ubi-micro", + "oci://registry.access.redhat.com/ubi9" + ] + + env = File.read!("ci/headless/bases.env") + + for %{"digest" => %{"sha256" => hex}} <- bases do + assert env =~ "@sha256:" <> hex + end + end + end + + describe "AC2: provenance is refused" do + test "for an image that names no commit (built from a modified tree)" do + for label <- [nil, ""] do + assert [msg] = Provenance.refusals(params(%{revision_label: label})) + assert msg =~ "names no commit in its org.opencontainers.image.revision label" + end + end + + test "for an image whose label is its base's (UBI9 micro names Red Hat's commit)" do + redhat = "b3f01943b83e7975e01233fc613cb5f0941b57d1" + assert [msg] = Provenance.refusals(params(%{revision_label: redhat})) + assert msg =~ ~s(is "#{redhat}", not --commit) + end + + test "for an image that names another commit" do + assert [msg] = Provenance.refusals(params(%{revision_label: @other})) + assert msg =~ ~s(is "#{@other}", not --commit #{@commit}) + end + + test "for an abbreviated commit, or no repository or builder" do + assert [msg] = Provenance.refusals(params(%{commit: "a1a1a1a", revision_label: "a1a1a1a"})) + assert msg =~ "is not a full 40-character" + + found = Provenance.refusals(params(%{repository: nil, builder_id: ""})) + assert "--repository is required" in found + assert "--builder-id is required" in found + end + + test "and accepted when the image names the commit" do + assert Provenance.refusals(params()) == [] + end + end + + test "the task refuses without its arguments" do + assert_raise Mix.Error, ~r/usage: mix trinity.image.provenance/, fn -> + Provenance.run(["--commit", @commit]) + end + end +end diff --git a/test/mix/tasks/trinity_image_slsa_test.exs b/test/mix/tasks/trinity_image_slsa_test.exs new file mode 100644 index 0000000..44dc2ba --- /dev/null +++ b/test/mix/tasks/trinity_image_slsa_test.exs @@ -0,0 +1,176 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Mix.Tasks.Trinity.Image.SlsaTest do + @moduledoc """ + Slice 131, AC4: the SLSA build level claimed for the headless image cannot be raised above + what the publishing workflow's mechanism supports, nor to L2 or more without a run that + verified, and the claim states its reason and why it is not the next level. + """ + use ExUnit.Case, async: true + + alias Mix.Tasks.Trinity.Image.Slsa + + @workflow ".github/workflows/headless-image.yml" + @claim "ci/headless/slsa.yaml" + @script "scripts/image_supply_chain.sh" + + defp yaml!(path) do + {:ok, map} = YamlElixir.read_from_file(path) + map + end + + defp workflow, do: yaml!(@workflow) + defp claim, do: yaml!(@claim) + defp script, do: File.read!(@script) + + defp publish_job(workflow), do: workflow["jobs"]["publish"] + + defp update_publish(fun), do: update_in(workflow(), ["jobs", "publish"], fun) + + defp without_step(job, pred), do: Map.update!(job, "steps", &Enum.reject(&1, pred)) + + describe "the tree" do + test "the claim stands against the workflow's mechanism" do + {level, _} = Slsa.justified(workflow(), script()) + assert Slsa.violations(claim(), level) == [] + end + + test "the claim is Build L1, the workflow carries L2's mechanism, and L3's is missing" do + assert claim()["build_level"] == 1 + {level, not_next} = Slsa.justified(workflow(), script()) + assert level == 2 + assert Enum.any?(not_next, &(&1 =~ "not by an isolated reusable workflow")) + + assert Enum.any?( + not_next, + &(&1 =~ "publish run their own steps while holding `id-token: write`") + ) + end + + test "the task passes on the tree" do + Slsa.run([]) + end + end + + describe "AC4 red: raising the claim without the mechanism fails" do + test "L2 claimed with no run that verified" do + raised = %{claim() | "build_level" => 2} + + assert Slsa.violations(raised, 2) == [ + "Build L2 is claimed without a workflow run whose verification passed " <> + "(`evidence:` lists none)" + ] + end + + test "L2 claimed with evidence that is not a workflow run" do + raised = %{claim() | "build_level" => 2, "evidence" => ["it worked on my machine"]} + assert [msg] = Slsa.violations(raised, 2) + assert msg =~ "without a workflow run" + end + + test "L2 claimed, with a run, once the platform provenance step is removed" do + wf = + update_publish( + &without_step(&1, fn s -> (s["uses"] || "") =~ "attest-build-provenance" end) + ) + + {level, missing} = Slsa.justified(wf, script()) + assert level == 1 + assert Enum.any?(missing, &(&1 =~ "no step asks GitHub for platform provenance")) + + raised = %{ + claim() + | "build_level" => 2, + "evidence" => ["https://github.com/ScriptKittyOS/Trinity/actions/runs/1"] + } + + assert Slsa.violations(raised, level) == [ + "Build L2 is claimed, and the workflow's mechanism supports only L1" + ] + end + + test "L3 claimed: no isolated provenance generator" do + raised = %{ + claim() + | "build_level" => 3, + "evidence" => ["https://github.com/ScriptKittyOS/Trinity/actions/runs/1"] + } + + {level, _} = Slsa.justified(workflow(), script()) + + assert Slsa.violations(raised, level) == [ + "Build L3 is claimed, and the workflow's mechanism supports only L2" + ] + end + + test "L1 claimed when nothing attaches provenance" do + wf = update_in(workflow(), ["jobs"], &Map.delete(&1, "publish")) + assert {0, [msg]} = Slsa.justified(wf, script()) + assert msg =~ "nothing attaches provenance" + + assert Slsa.violations(claim(), 0) == [ + "Build L1 is claimed, and the workflow's mechanism supports only L0" + ] + end + + test "L1's pieces: the provenance task and the script's provenance attestation" do + wf = + update_publish( + &without_step(&1, fn s -> (s["run"] || "") =~ "trinity.image.provenance" end) + ) + + assert {0, [msg]} = Slsa.justified(wf, script()) + assert msg =~ "does not generate provenance" + + assert {0, [msg]} = + Slsa.justified(workflow(), String.replace(script(), "slsaprovenance1", "custom")) + + assert msg =~ "does not attest a `slsaprovenance1` predicate" + end + + test "L2's pieces: a self-hosted runner, the permissions, the push, the verification" do + for {change, expected} <- [ + {&Map.put(&1, "runs-on", "self-hosted"), "GitHub-hosted runner"}, + {&put_in(&1, ["permissions", "id-token"], "none"), "`id-token: write`"}, + {fn job -> + Map.update!(job, "steps", fn steps -> + Enum.map(steps, fn s -> + if (s["uses"] || "") =~ "attest-build-provenance", + do: put_in(s, ["with", "push-to-registry"], false), + else: s + end) + end) + end, "not pushed to the registry"}, + {&without_step(&1, fn s -> (s["run"] || "") =~ "gh attestation verify" end), + "no step verifies the platform provenance"} + ] do + assert {1, missing} = Slsa.justified(update_publish(change), script()) + assert Enum.any?(missing, &(&1 =~ expected)), "#{expected} not in #{inspect(missing)}" + end + end + + test "a claim with no reason, or no word on the next level" do + assert Slsa.violations(Map.delete(claim(), "reason"), 2) == [ + "the claim states no reason for its level (`reason:`)" + ] + + assert Slsa.violations(%{claim() | "not_next" => " "}, 2) == [ + "the claim does not say why it is not L2 (`not_next:`)" + ] + end + + test "the task fails on a raised claim", %{} do + path = + Path.join(System.tmp_dir!(), "slsa-#{System.unique_integer([:positive])}.yaml") + + File.write!(path, File.read!(@claim) |> String.replace("build_level: 1", "build_level: 2")) + on_exit(fn -> File.rm(path) end) + + assert_raise Mix.Error, ~r/1 violation/, fn -> Slsa.run(["--claim", path]) end + end + end + + test "the publishing job is the one the check reads" do + assert publish_job(workflow())["needs"] == "image" + end +end diff --git a/test/support/fixtures/supply_chain/models.yaml b/test/support/fixtures/supply_chain/models.yaml new file mode 100644 index 0000000..a47cc14 --- /dev/null +++ b/test/support/fixtures/supply_chain/models.yaml @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Test fixture for `mix trinity.image.aibom` (slice 131, AC7): the entries `ci/headless/ai_models.yaml` +# would carry, shaped like slice 133's static model and slice 134's Tier 3 model. It is a fixture, +# not a statement about any image: no image carries these weights (slice 133, decision D7). +# +# Values, and where they came from: +# * The static model's space is `Trinity.Memory.Embedders.Static.space/0` on slice 133's branch +# (model, revision, the 256 int8 artifact's SHA-256, the upstream tokenizer's SHA-256). +# * Its licence and its 13 training datasets are the model card's metadata on Hugging Face +# (`license: apache-2.0`, `datasets:`), read 2026-10-08. +# * MS MARCO's terms are quoted from https://microsoft.github.io/msmarco/ as the 2026-10-07 +# embeddings answer sheet records them; the other datasets' licences have not been read from a +# primary source, so they are `unrecorded` until the legal review records them. +# * The Tier 3 model is slice 134's: Qwen3-Embedding-0.6B as its `Embedders.Ollama.space/0` builds +# the space (revision the service's /api/tags digest, weights the f16 GGUF's SHA-256), Apache 2.0 +# on the model card, and training data the publisher does not list. Fields this fixture does not +# know are `unrecorded`. +models: + - delivery: shipped + description: >- + Static embedding model, 256 dimensions, int8: the in-process Tier 1 embedder. + task: feature-extraction + architecture: static token embeddings, mean pooled + source: https://huggingface.co/sentence-transformers/static-retrieval-mrl-en-v1 + space: + model_id: sentence-transformers/static-retrieval-mrl-en-v1 + revision: f60985c706f192d45d218078e49e5a8b6f15283a + weights_digest: 5990c1104963d8e2402854c80b9a8f8e3a490085c037f25ced63642f4578c520 + tokenizer_digest: d241a60d5e8f04cc1b2b3e9ef7a4921b27bf526d9f6050ab90f9267a1f9e5c66 + dim: 256 + pooling: mean + normalisation: l2 + quantization: int8 + query_prompt: "" + document_prompt: "" + max_input: {tokens: none, truncation: none} + runtime: {name: trinity-static, version: "1"} + locality: in_process + num_ctx: none + weights: + file: static-retrieval-mrl-en-v1-256-int8.tsw + sha256: 5990c1104963d8e2402854c80b9a8f8e3a490085c037f25ced63642f4578c520 + licence: {id: Apache-2.0} + legal_review: + status: pending + record: licence and training-data terms, before any image carries the weights + risks: + - >- + Trained in part on MS MARCO, whose terms are for non-commercial research purposes only + and grant no licence; a commercial or government distribution of these weights depends on + the legal review. + datasets: + - name: sentence-transformers/msmarco-co-condenser-margin-mse-sym-mnrl-mean-v1 + source: https://huggingface.co/datasets/sentence-transformers/msmarco-co-condenser-margin-mse-sym-mnrl-mean-v1 + licence: &msmarco + name: MS MARCO terms + url: https://microsoft.github.io/msmarco/ + text: >- + intended for non-commercial research purposes only ... made available free of charge + without extending any license or other intellectual property rights + - name: bclavie/msmarco-10m-triplets + source: https://huggingface.co/datasets/bclavie/msmarco-10m-triplets + licence: *msmarco + - {name: sentence-transformers/gooaq, licence: unrecorded} + - {name: sentence-transformers/squad, licence: unrecorded} + - {name: sentence-transformers/s2orc, licence: unrecorded} + - {name: sentence-transformers/all-nli, licence: unrecorded} + - {name: sentence-transformers/paq, licence: unrecorded} + - {name: sentence-transformers/trivia-qa, licence: unrecorded} + - {name: nthakur/swim-ir-monolingual, licence: unrecorded} + - {name: sentence-transformers/pubmedqa, licence: unrecorded} + - {name: sentence-transformers/miracl, licence: unrecorded} + - {name: sentence-transformers/mldr, licence: unrecorded} + - {name: sentence-transformers/mr-tydi, licence: unrecorded} + + - delivery: operator-accepted + description: >- + Tier 3 embedder served by the operator's own Ollama; Trinity imports and verifies it, and + never ships its weights. + task: feature-extraction + source: https://huggingface.co/Qwen/Qwen3-Embedding-0.6B + space: + model_id: Qwen/Qwen3-Embedding-0.6B + revision: 4809f294c067b404132eff9d8c1da40885e5de6d5b9b1a3e5f35ba9f2d9dd5e4 + weights_digest: 421a27e58d165478cc7acb984a688c2aa41404968b0203e7cd743ece44c54340 + tokenizer_digest: unrecorded + dim: 1024 + pooling: last + normalisation: l2 + quantization: f32 + query_prompt: unrecorded + document_prompt: "" + max_input: {tokens: unrecorded, truncation: refuse} + runtime: {name: ollama, version: 0.40.0} + locality: within_boundary + num_ctx: 2048 + weights: + file: Qwen3-Embedding-0.6B-f16.gguf + sha256: 421a27e58d165478cc7acb984a688c2aa41404968b0203e7cd743ece44c54340 + licence: {id: Apache-2.0} + legal_review: + status: operator + record: pulled and accepted by the operator + risks: + - Training data is synthetic and not disclosed by the publisher. + datasets: + - name: Qwen3 synthetic training pairs + licence: + name: undisclosed + text: >- + Not disclosed by the publisher: weakly supervised pairs synthesized by Qwen3, then + supervised fine-tuning and model merging (arXiv 2506.05176). diff --git a/test/test_helper.exs b/test/test_helper.exs index be84b45..94511fb 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -10,13 +10,20 @@ # Slice 032: :local_model (the real embedder through the serving) runs where # TRINITY_LOCAL_MODEL_CACHE names a cache that already holds all-MiniLM-L6-v2 (no download, # no network: a missing model is a failure there, not a skip); excluded by tag elsewhere. +# Slice 131: :image_signing (the image's signatures and attestations through the real cosign, +# against a registry container on the loopback, and the documented verification sequence run end +# to end) runs where TRINITY_COSIGN names the cosign program; excluded by tag elsewhere, and where +# it is set, a missing cosign, docker or jq is a failure, never a skip. fips_leg? = System.get_env("TRINITY_FIPS_LEG") == "1" local_model? = System.get_env("TRINITY_LOCAL_MODEL_CACHE") not in [nil, ""] +image_signing? = System.get_env("TRINITY_COSIGN") not in [nil, ""] ExUnit.start( exclude: [:live, :desktop, :eval] ++ - if(fips_leg?, do: [], else: [:fips]) ++ if(local_model?, do: [], else: [:local_model]) + if(fips_leg?, do: [], else: [:fips]) ++ + if(local_model?, do: [], else: [:local_model]) ++ + if(image_signing?, do: [], else: [:image_signing]) ) # Slice 024's boot receipt is written by a transient Task after the tree starts; once the