Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
5 changes: 5 additions & 0 deletions src/vendoring/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
53 changes: 53 additions & 0 deletions src/vendoring/sbom.py
Original file line number Diff line number Diff line change
@@ -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))
5 changes: 5 additions & 0 deletions src/vendoring/tasks/vendor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
12 changes: 12 additions & 0 deletions tests/sample-projects/sbom/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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",
]
2 changes: 2 additions & 0 deletions tests/sample-projects/sbom/vendor.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
six==1.15.0
appdirs==1.4.4
58 changes: 58 additions & 0 deletions tests/test_sample_projects.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Test the various bits of functionality, through sample projects."""

import json
import linecache
import os
import shutil
Expand Down Expand Up @@ -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/[email protected]",
"name": "appdirs",
"purl": "pkg:pypi/[email protected]",
"type": "library",
"version": "1.4.4",
},
{
"bom-ref": "pkg:pypi/[email protected]",
"name": "six",
"purl": "pkg:pypi/[email protected]",
"type": "library",
"version": "1.15.0",
},
],
"dependencies": [
{
"dependsOn": ["pkg:pypi/[email protected]", "pkg:pypi/[email protected]"],
"ref": "bom-ref:sbom",
},
{"ref": "pkg:pypi/[email protected]"},
{"ref": "pkg:pypi/[email protected]"},
],
"metadata": {
"component": {"bom-ref": "bom-ref:sbom", "name": "sbom", "type": "library"},
"tools": [{"name": "vendoring", "version": "1.2.1.dev0"}],
},
"specVersion": "1.4",
"version": 1,
}
Loading