Sooner or later someone installing your CLI inside a company asks for "the SBOM". A software bill of materials is a machine-readable list of every component in a piece of software — name, version, package URL, usually licences and dependency relationships — in a standard format that security and compliance tooling can ingest. With it, a security team can answer "are we affected by the advisory published this morning?" for every tool they run without asking each vendor, and a legal team can check licences without reading forty METADATA files. For a Python CLI, producing one is mostly a matter of choosing the right source of truth and running one command in your release workflow. This guide covers both CycloneDX routes — from uv.lock and from a clean installation — adds a licence policy check, and attaches the result to each release. It belongs to the supply-chain security topic.
Prerequisites
- A CLI project with a lock file, ideally
uv.lock; uv 0.8 or later for SBOM export. uvxto runcyclonedx-py(from thecyclonedx-bompackage) without adding it to the project.- A release workflow, such as the one in automating releases from git tags.
Which SBOM describes your CLI?
The awkward truth about Python CLIs is that there is no single dependency set. Users who pipx install mytool get whatever versions satisfy your ranges on the day they install. What you can describe precisely is the set you locked, tested and — for binaries and container images — actually shipped.
- From the lock file — describes the exact tested environment, is fast, needs no installation, and is reproducible from the repository at the release tag. It carries names, versions, package URLs and the dependency graph, but not licences.
- From a clean installation of the built wheel — describes what actually got installed, and reads each package's metadata, so it includes licences, descriptions and project links. It takes a few seconds longer and needs a throwaway virtual environment.
- From the shipped artefact — for a PyInstaller binary or a container image, generate the SBOM from the exact environment that was bundled, or with an image scanner for containers, so it matches the bytes users run.
Most CLIs should publish the lock-based or install-based SBOM with every release, and an artefact-based one for each binary or image they distribute.
The recipe
From uv.lock
uv export --frozen --no-dev --format cyclonedx1.5 -o mytool-1.6.0.cdx.json
The result is a CycloneDX 1.5 JSON document: your project as the main component in metadata.component, every locked runtime package in components with a pkg:pypi/... package URL, and a dependencies section recording which package depends on which. --no-dev leaves out test and lint tools, which users never receive.
From a clean installation, with licences
uv build --wheel
uv venv /tmp/sbom-env
uv export --frozen --no-dev --no-emit-project --format requirements.txt -o /tmp/req.txt
VIRTUAL_ENV=/tmp/sbom-env uv pip install --require-hashes -r /tmp/req.txt
VIRTUAL_ENV=/tmp/sbom-env uv pip install --no-deps dist/mytool-1.6.0-py3-none-any.whl
uvx --from cyclonedx-bom cyclonedx-py environment /tmp/sbom-env \
--pyproject pyproject.toml --mc-type application \
--output-reproducible --of JSON -o mytool-1.6.0.cdx.json
Installing from the hashed export guarantees the environment matches the lock (see pinning dependencies with hashes). cyclonedx-py environment then reads the installed metadata, so each component carries its declared licence — as an SPDX identifier where the package provides one. --pyproject and --mc-type application describe your CLI itself as the main component, and --output-reproducible omits the timestamp and random serial number so the same inputs produce byte-identical output.
Checking the SBOM against a policy
An SBOM is also a convenient input for your own checks. This script reads a CycloneDX file, reports components whose licences are missing or not on an allowlist, and exits non-zero so it can gate a release:
# src/mytool_release/sbom_policy.py
from __future__ import annotations
import json
import sys
from pathlib import Path
ALLOWED = {"MIT", "BSD-2-Clause", "BSD-3-Clause", "Apache-2.0", "ISC", "PSF-2.0",
"MPL-2.0", "Unlicense"}
def licences(component: dict) -> set[str]:
found = set()
for entry in component.get("licenses", []):
lic = entry.get("license", {})
if lic.get("id"):
found.add(lic["id"])
elif entry.get("expression"):
found.add(entry["expression"])
return found
def check(sbom: dict, allowed: set[str] = ALLOWED,
exceptions: dict[str, str] | None = None) -> list[str]:
exceptions = exceptions or {}
problems = []
for c in sbom.get("components", []):
name = c.get("name", "?").lower()
if name in exceptions:
continue
found = licences(c)
if not found:
problems.append(f"{c.get('name')} {c.get('version')}: no licence declared")
elif not found & allowed:
problems.append(f"{c.get('name')} {c.get('version')}: {', '.join(sorted(found))}")
return problems
if __name__ == "__main__":
problems = check(json.loads(Path(sys.argv[1]).read_text()))
for p in problems:
print(f"LICENCE {p}", file=sys.stderr)
raise SystemExit(1 if problems else 0)
The exceptions mapping (package name to justification) handles the inevitable package that declares its licence only in a classifier or a free-text field; keep it in the repository next to the audit allowlist so both are reviewed the same way. This is a licence hygiene check, not legal advice: treat failures as prompts for a human to look, not as automatic verdicts.
Attaching it to the release
In the release workflow, generate the SBOM after building, attest it along with the other files, and upload it as a release asset:
- run: ./scripts/make-sbom.sh "${GITHUB_REF_NAME#v}" # the install-based commands above
- run: uv run --no-project python -m mytool_release.sbom_policy mytool-*.cdx.json
- uses: actions/attest-build-provenance@v2
with:
subject-path: "mytool-*.cdx.json"
- run: gh release upload "$GITHUB_REF_NAME" mytool-*.cdx.json
env:
GH_TOKEN: ${{ github.token }}
The policy check needs licence data, so the release uses the install-based route, wrapped in a small script that runs the commands from the previous section and writes mytool-<version>.cdx.json; a lock-based SBOM would fail the check with "no licence declared" for every component. Naming the file after the version and using the .cdx.json suffix — the CycloneDX convention — makes it easy for tooling to discover. Attesting it means users can verify that the inventory came from the same workflow as the release, as described in publishing attestations and verifying releases.
UX considerations
- Publish it where people look. A release asset next to the wheels and binaries, a link from
SECURITY.md, and a note in the README's installation section. - Say what it describes. "This SBOM lists the dependency versions mytool 1.6.0 was tested and built with; installs from PyPI may resolve newer compatible versions." Honesty here prevents confused security reviews.
- Match the format to the audience. CycloneDX is the common choice for security tooling; some compliance processes require SPDX. Tools such as
cyclonedx-pyand converters exist for both — produce what your users ask for. - Keep it reproducible. A stable SBOM for a given tag lets people diff two releases and see exactly which dependencies changed.
Testing the behaviour
The policy script is plain Python; test it with tiny SBOM fragments:
# tests/test_sbom_policy.py
from mytool_release.sbom_policy import check, licences
SBOM = {"components": [
{"name": "click", "version": "8.5.0", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
{"name": "weird", "version": "1.0", "licenses": []},
{"name": "gpl-thing", "version": "2.0", "licenses": [{"license": {"id": "GPL-3.0-only"}}]},
{"name": "dual", "version": "1.1", "licenses": [{"expression": "MIT OR Apache-2.0"}]},
]}
def test_licence_extraction_handles_ids_and_expressions():
assert licences(SBOM["components"][0]) == {"BSD-3-Clause"}
assert licences(SBOM["components"][3]) == {"MIT OR Apache-2.0"}
def test_policy_flags_missing_and_disallowed():
problems = check(SBOM, allowed={"BSD-3-Clause", "MIT OR Apache-2.0"})
assert problems == ["weird 1.0: no licence declared", "gpl-thing 2.0: GPL-3.0-only"]
def test_exceptions_skip_reviewed_packages():
assert check(SBOM, allowed={"BSD-3-Clause", "MIT OR Apache-2.0"},
exceptions={"weird": "MIT per LICENSE file", "gpl-thing": "build-only"}) == []
Add one integration test in CI that generates the SBOM from the real lock file and asserts every runtime dependency named in pyproject.toml appears among its components — a guard against an export flag silently dropping packages.
Conclusion
An SBOM is the inventory your users' security and legal teams need, and for a Python CLI it costs one command per release. Generate it from uv.lock for speed and reproducibility, or from a clean hashed install when you want licences; describe binaries and images from what they actually bundle; run a licence policy check; then attest it and attach it to the release with a sentence explaining what it describes.
Frequently asked questions
Does an SBOM replace vulnerability scanning?
No, it enables it. An SBOM is an inventory; tools such as pip-audit and dependency-track platforms compare inventories against advisories. Publishing the SBOM lets your users run those scans themselves, on their own schedule.
Should the SBOM include development dependencies?
Not the one you publish for users — they never install test or lint tools. A separate internal SBOM with development dependencies is useful for auditing your own build environment.
CycloneDX or SPDX?
Both are standards with broad tool support. CycloneDX is common in application security tooling and is what uv exports natively; SPDX is often requested in licence-compliance contexts. If a customer asks for one, generate that one; if nobody has asked, CycloneDX is a good default.
How do I produce an SBOM for a PyInstaller binary?
Build the binary from a virtual environment created from the hashed export, then run cyclonedx-py environment against that same environment before bundling. The SBOM then lists exactly the packages PyInstaller collected — plus, ideally, a component for the bundled Python interpreter itself, which you can add from your build metadata.