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 @@ def load_from_dict( "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 @@ def path_or_none(key: str) -> Optional[Path]: 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 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) 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, + }