Reproducible builds + SBOM

Every Nexo release ships with two artefacts that let an operator verify provenance and exact composition:

  1. CycloneDX SBOM (sbom-cyclonedx.json) — every cargo dependency at the exact version + hash that was compiled into the binary.
  2. SPDX SBOM (sbom-spdx.json) — full filesystem scan via syft, captures anything that wasn't a cargo dep (bundled binaries, generated assets, vendored data files).

Both SBOMs are Cosign-signed (*.bundle) using the same keyless OIDC chain documented in Verifying releases.

Reading the SBOMs

# Pretty-print the CycloneDX dep tree:
jq '.components | map({name, version, purl})' sbom-cyclonedx.json | less

# Find a specific crate:
jq '.components[] | select(.name == "tokio")' sbom-cyclonedx.json

# Audit the cargo deps with `cargo-audit` (run against the SBOM,
# without rebuilding):
cargo audit --db ~/.cargo/advisory-db --json | \
  jq -r '.vulnerabilities.list[].advisory.id'

Reproducible build claim

The release workflow targets bit-identical binary between two runs given the same git sha + rust-toolchain.toml + Cargo.lock. The pipeline pins:

  • Rust toolchain: rust-toolchain.toml fixes the channel + components.
  • Dependency versions: Cargo.lock is committed and --locked is used by every release build.
  • Build environment: GitHub Actions ubuntu-latest runner + cargo build --release with no RUSTFLAGS overrides.
  • Build provenance: SLSA Level 2 attestation generated by actions/attest-build-provenance (Phase 27.2 wiring).
  • Cosign signature: each binary + SBOM signed via OIDC (Phase 27.3).

Reproducing a release locally

# 1. Check out the exact tag.
git clone https://github.com/lordmacu/nexo-rs && cd nexo-rs
git checkout v0.1.1

# 2. Build with the locked deps.
rustup show       # confirms the toolchain matches rust-toolchain.toml
cargo build --release --bin nexo --locked

# 3. Compare your binary's sha256 against the release asset:
sha256sum target/release/nexo
# Expected: same hash listed in `sha256sums.txt` on the GitHub release.

If the hashes don't match: the build is not reproducible on your host. Common reasons:

  • Different glibc version → embedded __VERSIONED_SYMBOL strings drift. The release workflow runs on ubuntu-latest (currently Ubuntu 24.04, glibc 2.39); building on Debian 12 (glibc 2.36) produces different bytes.
  • Different LLVM in your local rustc build (rare, mostly affects Mac users compiling with stable + nightly side-by-side).
  • Local ~/.cargo/config.toml injecting RUSTFLAGS.
  • Build PROFILE-DEV vs PROFILE-RELEASE.

For a guaranteed bit-identical reproduction, build inside the same container the workflow uses:

docker run --rm -v $(pwd):/src -w /src \
  rust:1.80-bookworm \
  cargo build --release --bin nexo --locked

This reproduces what the GitHub Actions runner would do — same glibc, same toolchain version, same LLVM.

SLSA verification

The workflow attaches an attestation.intoto.jsonl (SLSA Level 2 provenance) per release. Verify with slsa-verifier:

go install github.com/slsa-framework/slsa-verifier/v2/cli/slsa-verifier@latest

slsa-verifier verify-artifact nexo \
  --provenance-path attestation.intoto.jsonl \
  --source-uri github.com/lordmacu/nexo-rs \
  --source-tag v0.1.1

A green verification proves:

  • The artefact came from the lordmacu/nexo-rs repo
  • It was built by a GitHub-hosted runner (not a fork or local box)
  • The build inputs match what's recorded in the provenance

Auditing for known CVEs

The SBOM lets cargo-audit work without rebuilding:

# Convert CycloneDX → cargo-audit's format:
cyclonedx-cli convert --input-format json \
  --output-format json sbom-cyclonedx.json | \
  jq '...' > deps.json

# Or just feed it to grype (broader scope, multi-format):
grype sbom:./sbom-cyclonedx.json
grype sbom:./sbom-spdx.json

Grype catches CVEs across both Rust crates and any system-level deps captured by syft.

Out of scope (deferred)

  • apk / pkg SBOM for the Termux deb — Termux's package metadata doesn't speak SPDX yet. The release SBOMs cover the same artifact contents though.
  • Reproducible Docker image layers — the current Dockerfile uses apt-get update && apt-get install which pulls whatever's latest at build time. Pinning to specific Debian package versions is a follow-up (Phase 34 hardening).