ADR-017: SBOM Generation¶
Status¶
Accepted Date: 2026-02-27 | Amended Date: 2026-10-05
Context¶
- Supply chain security is increasingly important (US EO 14028, EU CRA)
- cosalette is distributed as a PyPI wheel — consumers need to assess dependency risk
- No SBOM, attestation, or supply-chain tooling existed in the project
- Two dominant SBOM formats: CycloneDX (OWASP, security-focused) and SPDX (Linux Foundation, compliance-focused, ISO 5962:2021)
- Multiple generator tools exist: syft (Anchore), cdxgen, cyclonedx-python-lib, trivy
- The release workflow previously built the wheel independently in each publish job
Decision¶
-
Format: CycloneDX JSON — security-oriented, native VEX support, simpler schema, better fit for an IoT library. SPDX can be added later as a one-flag change.
-
Generator: syft (Anchore) — supports Python wheels natively, multi-format output, single binary, also handles OCI images if needed later.
-
Integration points:
- DevContainer: syft binary installed in Dockerfile (available for local
task sbom) - Taskfile:
task sbombuilds the wheel and generates CycloneDX JSON -
Release workflow: After building the wheel, syft generates a CycloneDX SBOM that is attached to the GitHub Release as a downloadable asset
-
Build-once publish-twice: The release workflow is refactored to build the wheel once and upload the same artifact to TestPyPI and PyPI, ensuring the SBOM accurately describes the published artifact.
Decision Drivers¶
- Providing supply chain transparency for consumers of the published wheel
- Aligning with Python ecosystem and OWASP security tooling conventions
- Minimising complexity — one format, one generator, automated in release pipeline
- Preserving ability to add SPDX later without architectural changes
Considered Options¶
SPDX instead of CycloneDX¶
- Advantages: ISO standard (5962:2021), required by some US government agencies
- Disadvantages: More verbose schema, license/compliance focus vs. security focus
- Rejected because: Current consumers are personal IoT bridges, not regulated entities. CycloneDX's security orientation is a better fit. Adding SPDX output is trivial (one syft flag) if demand arises.
SBOM inside the wheel¶
- Rejected because: Not standard practice. SBOMs are separate artifacts alongside the distribution, not embedded in it. PyPI does not host SBOMs.
No SBOM (status quo)¶
- Rejected because: Supply chain transparency is becoming non-optional for published libraries. Even for a small project, this is minimal effort with the right tooling.
Consequences¶
Positive¶
- Consumers can assess cosalette's dependency tree for known vulnerabilities
- CycloneDX JSON is machine-parseable by downstream tools (Dependency-Track, Grype, Trivy)
- Single build ensures wheel checksums match across TestPyPI and PyPI
- syft in devcontainer enables local SBOM generation during development
- Foundation for future supply chain improvements (attestations, SLSA provenance, VEX)
Negative¶
- DevContainer image grows by ~50MB (syft binary)
- Release workflow gains one additional step (minimal complexity)
- SBOM must be regenerated if dependencies change (automated via release workflow)
Neutral¶
- SPDX output can be layered on later without architectural changes
- PyPI attestations (PEP 740) and SLSA provenance are deferred to a future ADR
- DevContainer image SBOM is deferred (not relevant to end users)
Amendment (2026-10-05) — Additive¶
Rationale: syft reported zero components for a cosalette .whl because it does not look inside the wheel archive. ADR-087 platform wheels also carry a native cosalette-health binary whose Rust crates were invisible to the SBOM.
Additional Sub-Decision: Scan the unpacked wheel¶
scripts/wheel-sbom.sh unpacks the wheel and runs syft on the directory with --source-name cosalette --source-version <wheel version> and per-file entries disabled. The release workflow, PR wheel validation and task sbom use it. The SBOM covers the manylinux x86_64 platform wheel.
Additional Sub-Decision: Build the probe with cargo-auditable¶
scripts/bundle-health-probe.sh builds cosalette-health with cargo auditable build, which embeds the crate dependency list in a linker section that survives strip. syft reads it as pkg:cargo/... components, and grype can match them. CI installs a pinned cargo-auditable (CARGO_AUDITABLE_VERSION in rust-wheels.yml), and so does the devcontainer. The Linux build explicitly adds Cargo's install directory to PATH. wheel-sbom.sh fails if a wheel bundles the probe but the SBOM lists none of its crates.
Additional Considered Options¶
cargo-cyclonedx SBOM per crate
Generate a CycloneDX file from Cargo.lock with cargo-cyclonedx and merge it with the syft output.
- Advantages: No change to how the binary is built
- Disadvantages: Describes the lock file, not the shipped binary; Needs a second tool and an SBOM merge step
Additional Positive Consequences¶
- The release SBOM lists the cosalette package and every crate linked into the cosalette-health probe
- PR CI validates SBOM generation before publication; shell tests cover missing probe crate rejection
Additional Negative Consequences¶
- Every platform wheel job compiles cargo-auditable first (inside the manylinux/musllinux container on Linux)
- The pyo3 extension's crates are still not listed: maturin has no cargo-auditable option, and syft ignores the PEP 770 SBOM maturin writes to dist-info (tracked as cos-rl4a)
Amendment (2026-10-05) — Additive¶
Rationale: The previous amendment left the _filters_rs extension's crates (pyo3, pyo3-ffi, libc, once_cell) out of the release SBOM. maturin has no cargo-auditable option, but it already embeds a CycloneDX SBOM of the extension in dist-info/sboms (PEP 770). syft ignored that file only because of its name.
Additional Sub-Decision: Read the extension's PEP 770 SBOM with syft's sbom-cataloger¶
scripts/wheel-sbom.sh renames each dist-info/sboms/*.cyclonedx.json file in the unpacked wheel to *.cdx.json. It then runs syft with --select-catalogers +sbom-cataloger, which only reads SBOM files with names such as *.cdx.json. The release SBOM lists the extension's crates next to the probe's crates, and cites the renamed path as their location. The script fails if a wheel ships _filters_rs but the SBOM lists no pkg:cargo/pyo3 component.
Additional Considered Options¶
Wrap maturin's rustc with cargo-auditable
Set RUSTC_WORKSPACE_WRAPPER so that maturin's cargo build embeds auditable data in the extension.
- Advantages: Describes the shipped binary, like the probe
- Disadvantages: Relies on cargo-auditable's private CARGO_AUDITABLE_ORIG_ARGS protocol; Can break with any cargo-auditable or maturin release
Merge the embedded SBOM with cyclonedx-cli
Run cyclonedx-cli merge on the syft output and the maturin SBOM.
- Advantages: Merges the dependency graph as well as the components
- Disadvantages: Needs another pinned tool in CI and the devcontainer; Its merge output needs its own metadata fixes
Additional Positive Consequences¶
- The release SBOM lists every crate of both native binaries in a platform wheel, with no extra tool
Additional Negative Consequences¶
- maturin's SBOM is generated from Cargo metadata, so it also lists build-time crates (syn, quote, proc-macro2, pyo3-build-config) that are not linked into the extension
- The approach depends on syft's sbom-cataloger file name globs; if syft adds PEP 770 support, the rename can be dropped