From dc97209a73618061af3c0296af7c0f754e4cb344 Mon Sep 17 00:00:00 2001 From: Rohan Devasthale Date: Wed, 4 Nov 2026 15:04:09 -0500 Subject: [PATCH] fix(sbom): use wheel version instead of resolved version in SBOM When a patch adds a local version to a package, the SBOM reported the resolved upstream version rather than the version of the wheel it was embedded in, so the SBOM failed to identify its own wheel and vulnerability scanners could draw wrong conclusions. Pass the version parsed from the wheel filename to generate_sbom() as wheel_version. The downstream purl, wheel package entry, and document identity now use the actual wheel version (including local versions) automatically, without requiring a per-package purl.version override. The upstream package entry still identifies the resolved source release, and an explicit purl.version override continues to take precedence. Fixes: #1375 Co-Authored-By: Claude Code Signed-off-by: Rohan Devasthale --- src/fromager/packagesettings/_models.py | 7 +++- src/fromager/sbom.py | 12 +++++- src/fromager/wheels.py | 4 ++ tests/test_sbom.py | 52 +++++++++++++++++++++++++ tests/test_wheels.py | 39 ++++++++++++++++--- 5 files changed, 105 insertions(+), 9 deletions(-) diff --git a/src/fromager/packagesettings/_models.py b/src/fromager/packagesettings/_models.py index 7aa54e0c0..b244d0b0d 100644 --- a/src/fromager/packagesettings/_models.py +++ b/src/fromager/packagesettings/_models.py @@ -314,10 +314,15 @@ class PurlConfig(pydantic.BaseModel): """ version: str | None = None - """Override the purl version component (defaults to the resolved version) + """Override the purl version component (defaults to the wheel version) Also determines the version used in the SBOM document name and documentNamespace. + + .. versionchanged:: 0.102.0 + The default is now the version of the built wheel, including any + local version added by a patch, instead of the resolved upstream + version. """ repository_url: AnyUrl | None = None diff --git a/src/fromager/sbom.py b/src/fromager/sbom.py index 1ce7a4092..0c41dd984 100644 --- a/src/fromager/sbom.py +++ b/src/fromager/sbom.py @@ -91,6 +91,7 @@ def generate_sbom( ctx: context.WorkContext, req: Requirement, version: Version, + wheel_version: Version | None = None, ) -> dict[str, typing.Any]: """Generate a minimal SPDX 2.3 JSON document for a wheel. @@ -101,6 +102,13 @@ def generate_sbom( The document name and namespace are derived from the downstream purl (including any ``purl`` field overrides), so the document identity always matches the wheel package entry it describes. + + ``version`` is the resolved upstream version. ``wheel_version`` is + the version of the wheel actually built, which may differ when a + patch adds a local version (e.g. ``1.2.3+local.1``). The wheel + package entry, its purl, and the document identity use + ``wheel_version`` when given; the upstream package entry always + uses ``version``. """ sbom_settings = ctx.settings.sbom_settings if sbom_settings is None: @@ -116,7 +124,7 @@ def generate_sbom( downstream = _build_downstream_purl( name=name, - version=version, + version=wheel_version or version, pbi=pbi, sbom_settings=sbom_settings, ) @@ -134,7 +142,7 @@ def generate_sbom( # so the namespace is always a valid URI, even when purl overrides # contain URI-hostile characters. doc_name = downstream.name - doc_version = downstream.version or str(version) + doc_version = downstream.version or str(wheel_version or version) namespace = ( f"{sbom_settings.namespace!s}/{quote(doc_name, safe='')}" f"-{quote(doc_version, safe='')}-{uuid.uuid4()}.spdx.json" diff --git a/src/fromager/wheels.py b/src/fromager/wheels.py index 943edea8d..f5743aec3 100644 --- a/src/fromager/wheels.py +++ b/src/fromager/wheels.py @@ -320,6 +320,10 @@ def add_extra_metadata_to_wheels( ctx=ctx, req=req, version=version, + # dist_version comes from the wheel filename and includes + # any local version added by a patch, so the SBOM + # identifies the wheel that actually contains it + wheel_version=dist_version, ) sbom.write_sbom(sbom=sbom_doc, dist_info_dir=dist_info_dir) diff --git a/tests/test_sbom.py b/tests/test_sbom.py index b228c3fba..47e689a65 100644 --- a/tests/test_sbom.py +++ b/tests/test_sbom.py @@ -174,6 +174,58 @@ def test_generate_sbom_upstream_uses_source_version_without_upstream_override( _validate_spdx(doc) +def test_generate_sbom_wheel_version_used_without_purl_override( + tmp_path: pathlib.Path, +) -> None: + """Verify the wheel entry uses the actual wheel version automatically. + + When a patch adds a local version, the SBOM must identify the built + wheel without requiring a per-package ``purl.version`` override. + """ + ctx = make_sbom_ctx(tmp_path, sbom_settings=SbomSettings()) + doc = sbom.generate_sbom( + ctx=ctx, + req=Requirement("test-pkg==0.5.5"), + version=Version("0.5.5"), + wheel_version=Version("0.5.5+local.1"), + ) + + assert doc["name"] == "test-pkg-0.5.5+local.1" + wheel, upstream = doc["packages"] + assert wheel["versionInfo"] == "0.5.5+local.1" + assert wheel["externalRefs"][0]["referenceLocator"] == ( + "pkg:pypi/test-pkg@0.5.5%2Blocal.1" + ) + # Upstream still identifies the resolved source release + assert upstream["versionInfo"] == "0.5.5" + assert upstream["externalRefs"][0]["referenceLocator"] == ( + "pkg:pypi/test-pkg@0.5.5" + ) + _validate_spdx(doc) + + +def test_generate_sbom_purl_version_override_wins_over_wheel_version( + tmp_path: pathlib.Path, +) -> None: + """Verify an explicit purl.version override still takes precedence.""" + ctx = make_sbom_ctx( + tmp_path, + sbom_settings=SbomSettings(), + package_overrides={"purl": {"version": "0.5.5+corp.1"}}, + ) + doc = sbom.generate_sbom( + ctx=ctx, + req=Requirement("test-pkg==0.5.5"), + version=Version("0.5.5"), + wheel_version=Version("0.5.5+local.1"), + ) + + wheel = doc["packages"][0] + assert wheel["versionInfo"] == "0.5.5+corp.1" + assert doc["name"] == "test-pkg-0.5.5+corp.1" + _validate_spdx(doc) + + def test_generate_sbom_namespace_is_unique_per_document( tmp_path: pathlib.Path, ) -> None: diff --git a/tests/test_wheels.py b/tests/test_wheels.py index 91bb698e2..84f3cc8bf 100644 --- a/tests/test_wheels.py +++ b/tests/test_wheels.py @@ -1,3 +1,4 @@ +import json import pathlib import typing import zipfile @@ -139,7 +140,12 @@ def test_log_existing_sboms_when_absent( def test_add_extra_metadata_generates_sbom_when_enabled( mock_run: Mock, tmp_path: pathlib.Path ) -> None: - """Verify SBOM is generated in .dist-info/sboms/ when sbom settings are configured.""" + """Verify SBOM is generated in .dist-info/sboms/ when sbom settings are configured. + + The wheel carries a local version (as added by a patch) while the + resolved version has none, so the SBOM must describe the wheel it + is embedded in, not the upstream release. + """ sbom_ctx = make_sbom_ctx(tmp_path, sbom_settings=SbomSettings()) sbom_ctx.setup() req = Requirement("test_pkg==1.0.0") @@ -147,29 +153,30 @@ def test_add_extra_metadata_generates_sbom_when_enabled( wheel_dir = tmp_path / "wheel_build" wheel_dir.mkdir() - wheel_file = wheel_dir / "test_pkg-1.0.0-py3-none-any.whl" + wheel_file = wheel_dir / "test_pkg-1.0.0+local.1-py3-none-any.whl" with zipfile.ZipFile(wheel_file, "w") as zf: zf.writestr("test_pkg/__init__.py", "") zf.writestr( - "test_pkg-1.0.0.dist-info/METADATA", - "Name: test_pkg\nVersion: 1.0.0\n", + "test_pkg-1.0.0+local.1.dist-info/METADATA", + "Name: test_pkg\nVersion: 1.0.0+local.1\n", ) zf.writestr( - "test_pkg-1.0.0.dist-info/WHEEL", + "test_pkg-1.0.0+local.1.dist-info/WHEEL", "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", ) mock_run.return_value = "" # Create the repacked wheel that wheel pack would produce - repacked = wheel_dir / "test_pkg-1.0.0-0-py3-none-any.whl" + repacked = wheel_dir / "test_pkg-1.0.0+local.1-0-py3-none-any.whl" sdist_dir = tmp_path / "sdist" sdist_dir.mkdir() # Capture the wheel contents before repack by inspecting what wheel pack receives captured_contents: list[str] = [] + captured_sbom: dict[str, typing.Any] = {} def fake_run(cmd: list[str], **kwargs: object) -> str: # wheel pack is called with the unpacked dir as second arg @@ -177,6 +184,13 @@ def fake_run(cmd: list[str], **kwargs: object) -> str: for f in unpacked_dir.rglob("*"): if f.is_file(): captured_contents.append(str(f.relative_to(unpacked_dir))) + sbom_file = ( + unpacked_dir + / "test_pkg-1.0.0+local.1.dist-info" + / "sboms" + / ("fromager.spdx.json") + ) + captured_sbom.update(json.loads(sbom_file.read_text())) repacked.touch() return "" @@ -194,6 +208,19 @@ def fake_run(cmd: list[str], **kwargs: object) -> str: # Verify the SBOM file was added to the unpacked wheel before repacking assert any("sboms/fromager.spdx.json" in c for c in captured_contents) + # The SBOM must identify the wheel with its local version + assert captured_sbom["name"] == "test-pkg-1.0.0+local.1" + wheel_entry, upstream_entry = captured_sbom["packages"] + assert wheel_entry["versionInfo"] == "1.0.0+local.1" + assert wheel_entry["externalRefs"][0]["referenceLocator"] == ( + "pkg:pypi/test-pkg@1.0.0%2Blocal.1" + ) + # The upstream entry still identifies the resolved source release + assert upstream_entry["versionInfo"] == "1.0.0" + assert upstream_entry["externalRefs"][0]["referenceLocator"] == ( + "pkg:pypi/test-pkg@1.0.0" + ) + def test_download_wheel_unquotes_url_encoded_filenames(tmp_path: pathlib.Path) -> None: """Test that download_wheel properly unquotes URL-encoded characters in filenames."""