Project Setup

Generating an SBOM for a Python CLI Release

Produce a CycloneDX software bill of materials for a Python CLI from uv.lock or a clean install, check licences against a policy and attach it to releases.

Updated

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.
  • uvx to run cyclonedx-py (from the cyclonedx-bom package) 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.

Three ways to produce an SBOM A comparison of generating an SBOM from the lock file, from a clean installation and from a shipped artefact. Three ways to produce an SBOM Source Licences Describes uv export --format cyclonedx1.5 no the locked, tested set cyclonedx-py environment yes, from metadata a clean hashed install From the binary or image depends on tool the bytes users run PyPI installs may resolve newer versions — say what your SBOM describes.
  • 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.

An SBOM in the release The release workflow builds the wheel, installs it with hashed dependencies, generates a CycloneDX SBOM, checks licences, attests it and uploads it. An SBOM in the release Hashed install throwaway venv cyclonedx-py .cdx.json Policy check licences Attest + upload release asset env sbom ok --output-reproducible makes the same tag produce the same bytes.

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.

Generating and checking an SBOM Terminal session generating a CycloneDX SBOM, counting its components and running the licence policy check. Generating and checking an SBOM bash $ uvx --from cyclonedx-bom cyclonedx-py environment /tmp/sbom-env \ > --of JSON --output-reproducible -o mytool-1.6.0.cdx.json $ jq '.components | length' mytool-1.6.0.cdx.json 23 $ python -m mytool_release.sbom_policy mytool-1.6.0.cdx.json; echo $? 0 Twenty-three components a security team can now check without asking you.

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-py and 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.