DEV Community

Alessio
Alessio

Posted on Originally published at py-forge.com

How to Generate an SBOM from an Installed Python Environment

Software supply chain regulations, including the European Union Cyber Resilience Act, increasingly require teams to maintain an accurate Software Bill of Materials (SBOM). Many engineering workflows attempt to fulfill this by parsing static lockfiles like requirements.txt, Pipfile.lock, or poetry.lock.

Static manifests do not always reflect the true state of an execution environment. Packages can be manually upgraded, monkey-patched, installed with unrecorded flags, or introduced via operating system package managers. A declared dependency file records intent; it does not record what is physically installed on disk at runtime.

To achieve genuine inventory auditing, you must inspect the active environment directly. This article demonstrates how to use sbom_snapshot_installed_environment.py, a standalone Python tool that discovers installed packages across ecosystems, verifies their cryptographic metadata, and exports standard CycloneDX and SPDX documents without third-party dependencies.


Install & Usage

The script relies solely on the Python standard library and requires Python 3.8 or newer to leverage importlib.metadata. There are no external dependencies to install with pip.

To install the tool, download the single file, save it as sbom_snapshot_installed_environment.py, and run it with Python:

python sbom_snapshot_installed_environment.py --help
Enter fullscreen mode Exit fullscreen mode

Because the utility uses built-in modules—such as importlib.metadata, pathlib, hashlib, json, and subprocess—it can execute in isolated environments without needing external network access or installation overhead.

You can inspect command-line options via the help flag or import the core collection routines directly into your internal tooling to snapshot local environments programmatically.


How It Works

The tool is structured around three primary capabilities: multi-ecosystem component discovery, cryptographic integrity calculation, and schema generation.

1. Python Distribution Discovery

The script queries the Python runtime directly rather than invoking external shell processes. Using importlib.metadata, it walks through all distributions installed in the active environment's search path.

Here is the concrete discovery implementation from the tool:

def collect_python_components() -> List[Component]:
    """Discover actually installed Python distributions via importlib.metadata."""
    components: List[Component] = []
    seen: set = set()

    for dist in importlib.metadata.distributions():
        meta = dist.metadata
        name = meta.get("Name")
        if not name:
            continue
        version = meta.get("Version", "0.0.0")
        key = (name.lower(), version)
        if key in seen:
            continue
        seen.add(key)

        # Detect license
        license_str = meta.get("License-Expression") or meta.get("License") or "NOASSERTION"
        if len(license_str.splitlines()) > 1 or license_str.strip().upper() == "UNKNOWN":
            classifiers = meta.get_all("Classifier") or []
            license_classifiers = [c.split("::")[-1].strip() for c in classifiers if "License ::" in c]
            if license_classifiers:
                license_str = license_classifiers[0]
            else:
                license_str = "NOASSERTION"

        author = meta.get("Author") or meta.get("Author-email") or meta.get("Maintainer") or "UNKNOWN"
        summary = meta.get("Summary") or ""

        # Deterministic component hash based on RECORD file or metadata
        sha256_val = None
        install_path = None
        try:
            locate_res = dist.locate_file("")
            if locate_res:
                install_path = str(locate_res)
            record = dist.read_text("RECORD")
            if record:
                sha256_val = hashlib.sha256(record.encode("utf-8")).hexdigest()
            else:
                meta_text = dist.read_text("METADATA") or dist.read_text("PKG-INFO")
                if meta_text:
                    sha256_val = hashlib.sha256(meta_text.encode("utf-8")).hexdigest()
        except Exception:
            pass

        comp = Component(
            name=name,
            version=version,
            ecosystem="pypi",
            license_name=license_str,
            supplier=author,
            description=summary,
            sha256=sha256_val,
            installed_path=install_path,
        )
        components.append(comp)

    components.sort(key=lambda c: c.name.lower())
    return components
Enter fullscreen mode Exit fullscreen mode

This extraction logic handles several edge cases common to Python packaging:

  • If a package metadata entry contains an unstructured or multiline license description, the code inspects the package classifiers for an explicit License :: entry.
  • It computes a deterministic SHA-256 checksum from the package's installed RECORD manifest. If no RECORD is available, it hashes the installed METADATA or PKG-INFO text.
  • Each entry is converted to a normalized Package URL (pkg:pypi/<name>@<version>).

2. Node.js and Operating System Collectors

In addition to Python distributions, the code includes targeted collectors for other components resident on the machine:

  • Node.js: The collect_npm_components function recursively scans a specified node_modules directory for package.json manifests, ignoring hidden files, and records package versions, authors, and directory locations.
  • Debian / Ubuntu: The collect_dpkg_components function checks for dpkg-query via shutil.which and queries installed system packages.
  • Red Hat / RPM: The collect_rpm_components function executes rpm -qa when available to catalog host-level RPM packages.

3. Standards-Compliant SBOM Generation

Once components are collected into internal Component models, the tool exports them to standard formats such as CycloneDX 1.5 or SPDX 2.3.

The CycloneDX builder structures the output according to the official specification:

def build_cyclonedx(components: List[Component], doc_name: str = "Installed Environment Snapshot") -> Dict[str, Any]:
    """Generate CycloneDX v1.5 JSON representation."""
    now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
    bom_uuid = str(uuid.uuid4())

    cdx_components: List[Dict[str, Any]] = []
    for c in components:
        entry: Dict[str, Any] = {
            "type": "library" if c.ecosystem in ("pypi", "npm") else "operating-system",
            "name": c.name,
            "version": c.version,
            "purl": c.purl,
            "licenses": [{"license": {"name": c.license}}],
        }
        if c.description:
            entry["description"] = c.description
        if c.supplier and c.supplier != "UNKNOWN":
            entry["supplier"] = {"name": c.supplier}
        if c.sha256:
            entry["hashes"] = [{"alg": "SHA-256", "content": c.sha256}]
        cdx_components.append(entry)

    return {
        "bomFormat": "CycloneDX",
        "specVersion": "1.5",
        "serialNumber": f"urn:uuid:{bom_uuid}",
        "version": 1,
        "metadata": {
            "timestamp": now_iso,
            "tools": [{"vendor": "CRA-Auditor", "name": "sbom-snapshot-installed-environment", "version": "1.0.0"}],
            "component": {
                "type": "application",
                "name": doc_name,
                "version": "1.0.0",
            },
        },
        "components": cdx_components,
    }
Enter fullscreen mode Exit fullscreen mode

The output contains standard component declarations, unique serial numbers, UTC timestamps, cryptographic digests, and package URLs ready for ingestion by downstream vulnerability scanners.


Limitations

Before deploying the tool in production audit pipelines, keep the following technical boundaries in mind:

  1. Python Version Constraint: The script explicitly terminates on Python runtimes older than 3.8 because it relies on standard library features introduced in importlib.metadata.
  2. File Hashing Limits: The internal compute_file_sha256 function skips any file whose size exceeds 10 megabytes (10 * 1024 * 1024 bytes). Large distribution artifacts or nested assets above this size will not have an integrity hash attached.
  3. Host Tool Availability: System package discovery (dpkg and rpm) requires the respective utilities to be present in the execution path. If neither command is detected, the script silently ignores operating system packages and only reports application-level components.
  4. License String Heuristics: Licenses are parsed from declared package metadata or classifiers. If a package maintainer left license fields empty or entered non-standard identifiers without relevant classifiers, the script labels the license as NOASSERTION.
  5. Direct Directory Traversal for npm: The collect_npm_components function reads node_modules from disk. If packages are stored in alternative package-manager stores or symlinked without being populated in the specified path, they will not appear in the inventory.

Frequently Asked Questions

Does the script make external HTTP requests?

No. The tool runs completely offline. It reads metadata strictly from the local filesystem and host package databases, making it safe for air-gapped systems and isolated build agents.

What happens if a Python package does not have a RECORD file?

When a package is installed without a wheel record (for example, via older editable installations), the script falls back to reading the METADATA or PKG-INFO file. It then generates the SHA-256 hash across that metadata content instead.

Can this tool run without administrative privileges?

Yes. Inspecting Python distributions and local node_modules directories requires only read permissions for the target environment paths. System package queries (dpkg-query and rpm) also run in unprivileged query mode.


Full version and the rest of the toolkit: SBOM Snapshot installed environment on PyForge. Product page: https://py-forge.com/products/sbom-snapshot-installed-environment

Top comments (0)