Repository: pradyunsg/vendoring

Issue:
Add support for generating an SBOM documents
Vendoring downloads Python packages for local bundling. For users of vendoring to easily take advantage of PEP 770 and ship SBOMs detailing bundled dependencies I propose adding a simple flag which writes dependency information into an SBOM document:

```terminal
$ python -m vendoring ... --sbom=src/pip/_vendor/bom.cdx.json
```

Then projects can specify that same file for PEP 770 to automatically include the SBOM file describing bundled dependencies:

```toml
[project]
name = "pip"

[dist-info.files]
sboms = [
  "src/pip/_vendor/bom.cdx.json"
]
```

For simplicity I recommend using the CycloneDX 2.x SBOM format and only recording basic information (name, version, download location, etc). Additional information can be added later, if necessary.

I'm willing to create a patch for this feature.

Hints from issue comments:
@pfmoore: This would be excellent, especially as it would automate the collection and publication of license data for pip's dependencies (an issue being discussed in https://github.com/pypa/pip/pull/13335).

Solution patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5d858cc..80f532a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -12,7 +12,7 @@ jobs:
       - uses: actions/setup-python@v2
         with:
           python-version: 3.9
-      - uses: pre-commit/action@v2.0.0
+      - uses: pre-commit/action@v3.0.1
 
   tests:
     name: ${{ matrix.os }}
diff --git a/src/vendoring/configuration.py b/src/vendoring/configuration.py
index be14f70..521b110 100644
--- a/src/vendoring/configuration.py
+++ b/src/vendoring/configuration.py
@@ -43,6 +43,9 @@ class Configuration:
     # Overrides for which stub files are generated
     typing_stubs: Dict[str, List[str]]
 
+    # SBOM file
+    sbom_file: Optional[Path]
+
     @classmethod
     def load_from_dict(
         cls, dictionary: Dict[str, Any], *, location: Path
@@ -63,6 +66,7 @@ class Configuration:
                 "requirements": {"type": "string"},
                 "protected-files": {"type": "array", "items": {"type": "string"}},
                 "patches-dir": {"type": "string"},
+                "sbom-file": {"type": "string"},
                 "transformations": {
                     "type": "object",
                     "additionalProperties": False,
@@ -122,6 +126,7 @@ class Configuration:
             requirements=Path(dictionary["requirements"]),
             protected_files=dictionary.get("protected-files", []),
             patches_dir=path_or_none("patches-dir"),
+            sbom_file=path_or_none("sbom-file"),
             substitute=dictionary.get("transformations", {}).get("substitute", {}),
             drop_paths=dictionary.get("transformations", {}).get("drop", []),
             license_fallback_urls=dictionary.get("license", {}).get(
diff --git a/src/vendoring/sbom.py b/src/vendoring/sbom.py
new file mode 100644
index 0000000..a586553
--- /dev/null
+++ b/src/vendoring/sbom.py
@@ -0,0 +1,53 @@
+"""Code for generating a Software Bill-of-Materials (SBOM)
+from vendored libraries.
+"""
+
+import json
+from pathlib import Path
+from typing import Any, List
+from urllib.parse import quote
+
+from vendoring import __version__ as _vendoring_version
+from vendoring.tasks.update import parse_pinned_packages as _parse_pinned_packages
+
+
+def create_sbom_file(namespace: str, requirements: Path, sbom_file: Path) -> None:
+    # The top-most name in the module namespace is the
+    # most likely to be a recognizable name.
+    top_level = namespace.split(".", 1)[0]
+    top_level_bom_ref = f"bom-ref:{top_level}"
+    components: List[Any] = [
+        {"bom-ref": top_level_bom_ref, "name": top_level, "type": "library"}
+    ]
+    dependencies: List[Any] = [{"ref": top_level_bom_ref, "dependsOn": []}]
+    sbom = {
+        "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json",
+        "bomFormat": "CycloneDX",
+        "specVersion": "1.4",
+        "version": 1,
+        "metadata": {
+            "tools": [{"name": "vendoring", "version": _vendoring_version}],
+            "component": components[0],
+        },
+        "components": components,
+        "dependencies": dependencies,
+    }
+
+    pkgs = sorted(
+        _parse_pinned_packages(requirements), key=lambda item: (item.name, item.version)
+    )
+    for pkg in pkgs:
+        purl = f"pkg:pypi/{quote(pkg.name, safe='')}@{quote(pkg.version, safe='')}"
+        components.append(
+            {
+                "name": pkg.name,
+                "version": pkg.version,
+                "purl": purl,
+                "type": "library",
+                "bom-ref": purl,
+            }
+        )
+        dependencies[0]["dependsOn"].append(purl)
+        dependencies.append({"ref": purl})
+
+    sbom_file.write_text(json.dumps(sbom, indent=2, sort_keys=True))
diff --git a/src/vendoring/tasks/vendor.py b/src/vendoring/tasks/vendor.py
index 68e2026..3cf5223 100644
--- a/src/vendoring/tasks/vendor.py
+++ b/src/vendoring/tasks/vendor.py
@@ -6,6 +6,7 @@ from typing import Dict, List
 
 from vendoring.configuration import Configuration
 from vendoring.errors import VendoringError
+from vendoring.sbom import create_sbom_file
 from vendoring.ui import UI
 from vendoring.utils import remove_all as _remove_all
 from vendoring.utils import remove_matching_regex as _remove_matching_regex
@@ -156,6 +157,10 @@ def vendor_libraries(config: Configuration) -> List[str]:
     # Download the relevant libraries.
     download_libraries(config.requirements, destination)
 
+    # Generate an SBOM document for the requirements.
+    if config.sbom_file:
+        create_sbom_file(config.namespace, config.requirements, config.sbom_file)
+
     # Cleanup unnecessary directories/files created.
     remove_unnecessary_items(destination, config.drop_paths)
 


Test patch:
diff --git a/tests/sample-projects/sbom/pyproject.toml b/tests/sample-projects/sbom/pyproject.toml
new file mode 100644
index 0000000..5797513
--- /dev/null
+++ b/tests/sample-projects/sbom/pyproject.toml
@@ -0,0 +1,12 @@
+[tool.vendoring]
+destination = "vendored/"
+requirements = "vendor.txt"
+namespace = "sbom.vendored"
+
+sbom-file = "vendored/bom.cdx.json"
+
+[tool.vendoring.transformations]
+drop = [
+    "*.dist-info",
+    "*.egg-info",
+]
diff --git a/tests/sample-projects/sbom/vendor.txt b/tests/sample-projects/sbom/vendor.txt
new file mode 100644
index 0000000..072e160
--- /dev/null
+++ b/tests/sample-projects/sbom/vendor.txt
@@ -0,0 +1,2 @@
+six==1.15.0
+appdirs==1.4.4
diff --git a/tests/test_sample_projects.py b/tests/test_sample_projects.py
index ca45493..957cc34 100644
--- a/tests/test_sample_projects.py
+++ b/tests/test_sample_projects.py
@@ -1,5 +1,6 @@
 """Test the various bits of functionality, through sample projects."""
 
+import json
 import linecache
 import os
 import shutil
@@ -226,3 +227,60 @@ def test_typing_fun(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
         "moves",
     ]
     assert (six / "moves" / "__init__.pyi").exists()
+
+
+def test_sbom(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+    shutil.copytree(SAMPLE_PROJECTS / "sbom", tmp_path, dirs_exist_ok=True)
+    monkeypatch.chdir(tmp_path)
+
+    result = run_vendoring_sync()
+    assert result.exit_code == 0
+
+    vendored = tmp_path / "vendored"
+    assert vendored.exists()
+    assert sorted(os.listdir(vendored)) == [
+        "appdirs.LICENSE.txt",
+        "appdirs.py",
+        "appdirs.pyi",
+        "bom.cdx.json",
+        "six.LICENSE",
+        "six.py",
+        "six.pyi",
+    ]
+
+    sbom_data = json.loads((vendored / "bom.cdx.json").read_text())
+    assert sbom_data == {
+        "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json",
+        "bomFormat": "CycloneDX",
+        "components": [
+            {"bom-ref": "bom-ref:sbom", "name": "sbom", "type": "library"},
+            {
+                "bom-ref": "pkg:pypi/appdirs@1.4.4",
+                "name": "appdirs",
+                "purl": "pkg:pypi/appdirs@1.4.4",
+                "type": "library",
+                "version": "1.4.4",
+            },
+            {
+                "bom-ref": "pkg:pypi/six@1.15.0",
+                "name": "six",
+                "purl": "pkg:pypi/six@1.15.0",
+                "type": "library",
+                "version": "1.15.0",
+            },
+        ],
+        "dependencies": [
+            {
+                "dependsOn": ["pkg:pypi/appdirs@1.4.4", "pkg:pypi/six@1.15.0"],
+                "ref": "bom-ref:sbom",
+            },
+            {"ref": "pkg:pypi/appdirs@1.4.4"},
+            {"ref": "pkg:pypi/six@1.15.0"},
+        ],
+        "metadata": {
+            "component": {"bom-ref": "bom-ref:sbom", "name": "sbom", "type": "library"},
+            "tools": [{"name": "vendoring", "version": "1.2.1.dev0"}],
+        },
+        "specVersion": "1.4",
+        "version": 1,
+    }