Software Bill of Materials (mcptest sbom)
Every release of mcptest carries a CycloneDX 1.5 Software Bill of Materials baked into the binary at compile time. The mcptest sbom subcommand reads it back out so a reviewer can answer "what does this thing depend on" without trusting any external SBOM file.
What gets embedded
The build.rs script runs cargo metadata on the workspace, walks the locked dependency graph, and writes a CycloneDX 1.5 document into the build's OUT_DIR. The CLI embeds the file via include_str!, so the BOM travels with the binary and survives a strip(1).
Each component carries:
name,version,type: library- A
purlof the formpkg:cargo/<name>@<version> - A
bom-refequal to the purl, so cross-references resolve - The SPDX license expression from the crate's manifest
- A short description (when the crate sets one)
- A
vcsexternal reference (when the crate sets a repository)
The build also writes the SHA-256 of the serialized BOM into a sibling file the binary embeds, so mcptest sbom --verify can confirm the embedded bytes have not been swapped at runtime.
Reading the SBOM from the binary
The default invocation prints the raw CycloneDX JSON to stdout:
mcptest sbom > mcptest.cdx.json
Pipe it into any CycloneDX-aware scanner. cyclonedx-cli validate, Dependency-Track, Trivy, Grype, Snyk, OWASP DC, and Sigstore policy controller all consume this format directly.
Two summary modes are also available:
mcptest sbom --format licenses
prints one line per dep with its SPDX expression, useful when the question is "is there anything in here that is not OSI-approved":
adler2 2.0.1 0BSD OR MIT OR Apache-2.0
ahash 0.8.12 MIT OR Apache-2.0
anyhow 1.0.102 MIT OR Apache-2.0
...
And:
mcptest sbom --format names
prints one line per dep with just the name and version, useful for piping into other tools:
adler2 2.0.1
ahash 0.8.12
anyhow 1.0.102
...
Verifying the embedded BOM
mcptest sbom --verify
re-hashes the embedded BOM bytes at runtime and compares against the SHA-256 the build stamped in. On match it prints:
mcptest sbom: embedded BOM verified
sha256 : <hash>
components : <count>
and exits 0. On mismatch it prints the expected and actual hashes and exits 2.
What this check proves and does not prove:
- It proves the embedded BOM bytes in the running binary match the bytes the build wrote. A modified copy where someone swapped the JSON for a shorter list of "trusted" deps would fail this check.
- It does not prove the binary itself matches a published release. The hash is computed by the same binary that contains the bytes; a determined attacker who rebuilt
build.rsto lie would defeat it.
To verify the binary matches a published release, use the signed release artifacts. Every release ships cosign signatures and a SLSA provenance attestation:
cosign verify-blob \
--certificate-identity-regexp '/soapbucket/mcptest/' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
--signature mcptest.sig \
mcptest
gh attestation verify mcptest --repo soapbucket/mcptest
The first command verifies the binary was signed by the mcptest release workflow identity. The second confirms the SLSA provenance: this exact binary was built from this exact commit by this exact GitHub Actions workflow.
Reproducibility
The BOM omits its timestamp field unless SOURCE_DATE_EPOCH is set in the build environment. With Cargo.lock committed and pinned and the rust toolchain pinned, two clean builds on the same machine produce byte-identical SBOMs (and the same bom.sha256). The CI release workflow sets SOURCE_DATE_EPOCH from the tagged commit's date so the published BOM is reproducible from the same commit.
File this under
- A new dep added to
Cargo.lockshows up automatically in the next build's BOM. The build script reruns onCargo.lockchanges. - A dep removed from
Cargo.lockdrops out automatically. - The
NOTICEfile at the workspace root is the human-readable attribution counterpart; the CycloneDX BOM is the machine-readable one. Both must be in sync withCargo.lock.
What is intentionally out of scope
- SPDX format: the CycloneDX BOM converts to SPDX via
cyclonedx-cli convert --output-format spdx_json; we do not embed a second copy. - Vulnerability data: an SBOM lists components, not CVEs. Run the output through a scanner (Trivy, Grype, OSV-Scanner) for vuln signal.
- Runtime library calls: this is a Rust binary statically linked against the listed crates; there is no plugin loader and no dlopen at runtime, so the build-time list is the runtime list.