-
Notifications
You must be signed in to change notification settings - Fork 49
feat(sbom): build purls with packageurl-python #1032
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -37,6 +37,8 @@ class SbomSettings(pydantic.BaseModel): | |||||||||||||||
| sbom: | ||||||||||||||||
| supplier: "Organization: ExampleCo" | ||||||||||||||||
| namespace: "https://www.example.com" | ||||||||||||||||
| purl_type: pypi | ||||||||||||||||
| repository_url: "https://example.com/simple" | ||||||||||||||||
| creators: | ||||||||||||||||
| - "Organization: ExampleCo" | ||||||||||||||||
| """ | ||||||||||||||||
|
|
@@ -55,6 +57,64 @@ class SbomSettings(pydantic.BaseModel): | |||||||||||||||
| The fromager tool creator entry is always added automatically. | ||||||||||||||||
| """ | ||||||||||||||||
|
|
||||||||||||||||
| purl_type: str = "pypi" | ||||||||||||||||
| """Default purl type for all packages (e.g. ``pypi``, ``generic``)""" | ||||||||||||||||
|
|
||||||||||||||||
| repository_url: str | None = None | ||||||||||||||||
| """Default purl ``repository_url`` qualifier for all packages | ||||||||||||||||
|
|
||||||||||||||||
| When set, this URL is added to every purl as a qualifier | ||||||||||||||||
| (e.g. ``pkg:pypi/flask@2.0?repository_url=https://example.com/simple``). | ||||||||||||||||
| Can be overridden per-package in the package settings file. | ||||||||||||||||
| """ | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| class PurlConfig(pydantic.BaseModel): | ||||||||||||||||
| """Per-package purl configuration for SBOM generation. | ||||||||||||||||
|
|
||||||||||||||||
| Allows overriding individual purl components or specifying an | ||||||||||||||||
| upstream purl for packages sourced from GitHub/GitLab. | ||||||||||||||||
|
|
||||||||||||||||
| :: | ||||||||||||||||
|
|
||||||||||||||||
| purl: | ||||||||||||||||
| type: generic | ||||||||||||||||
| name: custom-name | ||||||||||||||||
| repository_url: "https://example.com/simple" | ||||||||||||||||
| upstream: "pkg:github/org/repo@v1.0.0" | ||||||||||||||||
| """ | ||||||||||||||||
|
|
||||||||||||||||
| model_config = MODEL_CONFIG | ||||||||||||||||
|
|
||||||||||||||||
| type: str | None = None | ||||||||||||||||
| """Override the purl type (e.g. ``generic`` instead of ``pypi``)""" | ||||||||||||||||
|
|
||||||||||||||||
| namespace: str | None = None | ||||||||||||||||
| """Override the purl namespace component""" | ||||||||||||||||
|
|
||||||||||||||||
| name: str | None = None | ||||||||||||||||
| """Override the purl name component (defaults to the package name)""" | ||||||||||||||||
|
|
||||||||||||||||
| version: str | None = None | ||||||||||||||||
| """Override the purl version component (defaults to the resolved version)""" | ||||||||||||||||
|
|
||||||||||||||||
| repository_url: str | None = None | ||||||||||||||||
| """Per-package override for the purl ``repository_url`` qualifier. | ||||||||||||||||
|
|
||||||||||||||||
| Overrides the global ``sbom.repository_url`` setting for this package. | ||||||||||||||||
| """ | ||||||||||||||||
|
|
||||||||||||||||
| upstream: str | None = None | ||||||||||||||||
| """Full purl string identifying the upstream source package. | ||||||||||||||||
|
|
||||||||||||||||
| When set, this is used as the upstream identity in the SBOM's | ||||||||||||||||
| GENERATED_FROM relationship. Used for packages sourced from | ||||||||||||||||
| GitHub/GitLab rather than PyPI. | ||||||||||||||||
|
|
||||||||||||||||
| When absent, the upstream purl is auto-derived from the downstream | ||||||||||||||||
| purl without the ``repository_url`` qualifier. | ||||||||||||||||
| """ | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| class ResolverDist(pydantic.BaseModel): | ||||||||||||||||
| """Packages resolver dist | ||||||||||||||||
|
|
@@ -351,12 +411,11 @@ class PackageSettings(pydantic.BaseModel): | |||||||||||||||
| download_source: DownloadSource = Field(default_factory=DownloadSource) | ||||||||||||||||
| """Alternative source download settings""" | ||||||||||||||||
|
|
||||||||||||||||
| purl: str | None = None | ||||||||||||||||
| """Package URL (purl) override for SBOM generation | ||||||||||||||||
| purl: PurlConfig | None = None | ||||||||||||||||
| """Purl configuration for SBOM generation. | ||||||||||||||||
|
|
||||||||||||||||
| When set, this value is used instead of the default ``pkg:pypi/<name>@<version>`` | ||||||||||||||||
| purl. Useful for packages that are not on PyPI or are midstream forks. | ||||||||||||||||
| Supports ``{name}`` and ``{version}`` format substitution. | ||||||||||||||||
| A ``PurlConfig`` object with individual field overrides and upstream | ||||||||||||||||
| source identification. | ||||||||||||||||
|
Comment on lines
+417
to
+418
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a backwards incompatible change and needs a
Suggested change
|
||||||||||||||||
| """ | ||||||||||||||||
|
|
||||||||||||||||
| resolver_dist: ResolverDist = Field(default_factory=ResolverDist) | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -13,39 +13,73 @@ | |||||
| import typing | ||||||
| from datetime import UTC, datetime | ||||||
|
|
||||||
| from packageurl import PackageURL | ||||||
| from packaging.requirements import Requirement | ||||||
| from packaging.utils import canonicalize_name | ||||||
| from packaging.version import Version | ||||||
|
|
||||||
| if typing.TYPE_CHECKING: | ||||||
| from . import context | ||||||
| from .packagesettings import PackageBuildInfo, SbomSettings | ||||||
|
|
||||||
| logger = logging.getLogger(__name__) | ||||||
|
|
||||||
| SBOM_FILENAME = "fromager.spdx.json" | ||||||
|
|
||||||
|
|
||||||
| def _build_purl( | ||||||
| def _build_downstream_purl( | ||||||
| *, | ||||||
| package_name: str, | ||||||
| package_version: Version, | ||||||
| purl_override: str | None, | ||||||
| ) -> str: | ||||||
| """Build a package URL for the SBOM. | ||||||
|
|
||||||
| Returns ``pkg:pypi/<name>@<version>`` by default. If a purl override | ||||||
| is set in per-package settings, it is used instead with | ||||||
| ``str.format()`` substitution for ``{name}`` and ``{version}``. | ||||||
| name: str, | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| version: Version, | ||||||
| pbi: PackageBuildInfo, | ||||||
| sbom_settings: SbomSettings, | ||||||
| ) -> PackageURL: | ||||||
| """Build the downstream package URL for the wheel. | ||||||
|
|
||||||
| A purl is constructed from ``PurlConfig`` field overrides | ||||||
| (per-package) falling back to global defaults. | ||||||
| """ | ||||||
| if purl_override: | ||||||
| try: | ||||||
| return purl_override.format(name=package_name, version=package_version) | ||||||
| except (KeyError, ValueError) as err: | ||||||
| raise ValueError( | ||||||
| f"invalid purl template {purl_override!r}: " | ||||||
| "only {name} and {version} are supported" | ||||||
| ) from err | ||||||
| return f"pkg:pypi/{package_name}@{package_version}" | ||||||
| pc = pbi.purl_config | ||||||
| purl_type = (pc.type if pc else None) or sbom_settings.purl_type | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor nit pick: I find the |
||||||
| qualifiers: dict[str, str] = {} | ||||||
| repo_url = (pc.repository_url if pc else None) or sbom_settings.repository_url | ||||||
| if repo_url: | ||||||
| qualifiers["repository_url"] = repo_url | ||||||
|
|
||||||
| return PackageURL( | ||||||
| type=purl_type, | ||||||
| namespace=pc.namespace if pc else None, | ||||||
| name=(pc.name if pc else None) or name, | ||||||
| version=(pc.version if pc else None) or str(version), | ||||||
| qualifiers=qualifiers or None, | ||||||
| ) | ||||||
|
|
||||||
|
|
||||||
| def _build_upstream_purl( | ||||||
| *, | ||||||
| name: str, | ||||||
| version: Version, | ||||||
| pbi: PackageBuildInfo, | ||||||
| sbom_settings: SbomSettings, | ||||||
| ) -> PackageURL: | ||||||
| """Build the upstream source package URL. | ||||||
|
|
||||||
| If ``upstream`` is set in the per-package ``PurlConfig``, it is | ||||||
| used as-is. Otherwise, the upstream purl is derived from the same | ||||||
| base as the downstream purl but without the ``repository_url`` | ||||||
| qualifier. | ||||||
| """ | ||||||
| pc = pbi.purl_config | ||||||
| if pc and pc.upstream: | ||||||
| return PackageURL.from_string(pc.upstream) | ||||||
|
|
||||||
| purl_type = (pc.type if pc else None) or sbom_settings.purl_type | ||||||
| return PackageURL( | ||||||
| type=purl_type, | ||||||
| namespace=pc.namespace if pc else None, | ||||||
| name=(pc.name if pc else None) or name, | ||||||
| version=(pc.version if pc else None) or str(version), | ||||||
| ) | ||||||
|
|
||||||
|
|
||||||
| def generate_sbom( | ||||||
|
|
@@ -56,8 +90,9 @@ def generate_sbom( | |||||
| ) -> dict[str, typing.Any]: | ||||||
| """Generate a minimal SPDX 2.3 JSON document for a wheel. | ||||||
|
|
||||||
| The document contains the wheel as the primary package and a | ||||||
| DESCRIBES relationship from the document to the package. | ||||||
| The document contains the downstream wheel as the primary package, | ||||||
| the upstream source as a second package, and DESCRIBES / | ||||||
| GENERATED_FROM relationships. | ||||||
| """ | ||||||
| sbom_settings = ctx.settings.sbom_settings | ||||||
| if sbom_settings is None: | ||||||
|
|
@@ -73,26 +108,48 @@ def generate_sbom( | |||||
|
|
||||||
| namespace = f"{sbom_settings.namespace}/{name}-{version}.spdx.json" | ||||||
|
|
||||||
| package_entry: dict[str, typing.Any] = { | ||||||
| downstream = _build_downstream_purl( | ||||||
| name=name, | ||||||
| version=version, | ||||||
| pbi=pbi, | ||||||
| sbom_settings=sbom_settings, | ||||||
| ) | ||||||
| upstream = _build_upstream_purl( | ||||||
| name=name, | ||||||
| version=version, | ||||||
| pbi=pbi, | ||||||
| sbom_settings=sbom_settings, | ||||||
| ) | ||||||
|
|
||||||
| wheel_entry: dict[str, typing.Any] = { | ||||||
| "SPDXID": "SPDXRef-wheel", | ||||||
| "name": name, | ||||||
| "versionInfo": str(version), | ||||||
| "name": downstream.name, | ||||||
| "versionInfo": downstream.version or str(version), | ||||||
| "downloadLocation": "NOASSERTION", | ||||||
| "supplier": sbom_settings.supplier, | ||||||
| "externalRefs": [ | ||||||
| { | ||||||
| "referenceCategory": "PACKAGE-MANAGER", | ||||||
| "referenceType": "purl", | ||||||
| "referenceLocator": downstream.to_string(), | ||||||
| } | ||||||
| ], | ||||||
| } | ||||||
|
|
||||||
| purl = _build_purl( | ||||||
| package_name=name, | ||||||
| package_version=version, | ||||||
| purl_override=pbi.purl, | ||||||
| ) | ||||||
| package_entry["externalRefs"] = [ | ||||||
| { | ||||||
| "referenceCategory": "PACKAGE-MANAGER", | ||||||
| "referenceType": "purl", | ||||||
| "referenceLocator": purl, | ||||||
| } | ||||||
| ] | ||||||
| upstream_entry: dict[str, typing.Any] = { | ||||||
| "SPDXID": "SPDXRef-upstream", | ||||||
| "name": upstream.name, | ||||||
| "versionInfo": upstream.version or str(version), | ||||||
| "downloadLocation": "NOASSERTION", | ||||||
| "supplier": "NOASSERTION", | ||||||
| "externalRefs": [ | ||||||
| { | ||||||
| "referenceCategory": "PACKAGE-MANAGER", | ||||||
| "referenceType": "purl", | ||||||
| "referenceLocator": upstream.to_string(), | ||||||
| } | ||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||
| ], | ||||||
| } | ||||||
|
|
||||||
| doc: dict[str, typing.Any] = { | ||||||
| "spdxVersion": "SPDX-2.3", | ||||||
|
|
@@ -104,13 +161,18 @@ def generate_sbom( | |||||
| "created": timestamp, | ||||||
| "creators": creators, | ||||||
| }, | ||||||
| "packages": [package_entry], | ||||||
| "packages": [wheel_entry, upstream_entry], | ||||||
| "relationships": [ | ||||||
| { | ||||||
| "spdxElementId": "SPDXRef-DOCUMENT", | ||||||
| "relationshipType": "DESCRIBES", | ||||||
| "relatedSpdxElement": "SPDXRef-wheel", | ||||||
| }, | ||||||
| { | ||||||
| "spdxElementId": "SPDXRef-wheel", | ||||||
| "relationshipType": "GENERATED_FROM", | ||||||
| "relatedSpdxElement": "SPDXRef-upstream", | ||||||
| }, | ||||||
| ], | ||||||
| } | ||||||
| return doc | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add a ``versionadded
[directive](https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html) and include the new model indocs/reference/config-reference.rst`