From c7572dae9de11b135facaaec0d1f9ce2e45b0542 Mon Sep 17 00:00:00 2001 From: Kendall Harter Date: Mon, 22 Jun 2026 13:12:37 -0700 Subject: [PATCH 01/11] Generate NuGet PURL's --- pyproject.toml | 1 + surfactant/cmd/generate.py | 4 ++ surfactant/plugin/manager.py | 2 + surfactant/relationships/nuget_purl.py | 75 ++++++++++++++++++++++++++ 4 files changed, 82 insertions(+) create mode 100644 surfactant/relationships/nuget_purl.py diff --git a/pyproject.toml b/pyproject.toml index 84d82a1e..efa7b863 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ dependencies = [ "python-msi==0.0.0b3", # Pinned to specific version for potential breaking changes "rpmfile==2.2.1", + "requests==2.*", ] dynamic = ["version"] diff --git a/surfactant/cmd/generate.py b/surfactant/cmd/generate.py index 712cc5fa..d6bcbbdb 100644 --- a/surfactant/cmd/generate.py +++ b/surfactant/cmd/generate.py @@ -884,6 +884,10 @@ def sbom( # add "Uses" relationships based on gathered metadata for software entries if not skip_relationships: + # Init the relationship hooks here + call_init_hooks( + pm, hook_filter=["establish_relationships"], command_name="generate" + ) parse_relationships(pm, new_sbom) else: logger.info("Skipping relationships based on imports metadata") diff --git a/surfactant/plugin/manager.py b/surfactant/plugin/manager.py index 0abac660..3198f782 100644 --- a/surfactant/plugin/manager.py +++ b/surfactant/plugin/manager.py @@ -42,6 +42,7 @@ def _register_plugins(pm: pluggy.PluginManager) -> None: dotnet_relationship, elf_relationship, java_relationship, + nuget_purl, pe_relationship, rpmfile_relationship, ) @@ -64,6 +65,7 @@ def _register_plugins(pm: pluggy.PluginManager) -> None: dotnet_relationship, elf_relationship, java_relationship, + nuget_purl, pe_relationship, rpmfile_relationship, csv_writer, diff --git a/surfactant/relationships/nuget_purl.py b/surfactant/relationships/nuget_purl.py new file mode 100644 index 00000000..16335a9b --- /dev/null +++ b/surfactant/relationships/nuget_purl.py @@ -0,0 +1,75 @@ +# Copyright 2026 Lawrence Livermore National Security, LLC +# See the top-level LICENSE file for details. +# +# SPDX-License-Identifier: MIT + +import requests + +from loguru import logger + +import surfactant.plugin +from surfactant.sbomtypes import SBOM, Relationship, Software, NameEntry + +class __NuGetManager: + def __init__(self): + self.disabled = True + + def init_urls(self): + # Get the base PackageBaseAddress URL + r = requests.get("https://api.nuget.org/v3/index.json") + if r.status_code != 200: + logger.warning(f"NuGet API returned {r.status_code}; disabling") + self.disabled = True + return + + self.disabled = False + self.package_base_address = [x["@id"] for x in r.json()["resources"] if x["@type"] == "PackageBaseAddress/3.0.0"] + # remove trailing "/" if present + for i, pba in enumerate(self.package_base_address): + if pba[-1] == "/": + self.package_base_address[i] = pba[:-1] + + + def get_package_url(self, package_name: str, package_version: str) -> str | None: + if self.disabled: + return None + + for url in self.package_base_address: + r = requests.get(f"{url}/{package_name.lower()}/index.json") + if r.status_code != 200: + continue + + if versions := r.json()["versions"]: + if package_version in versions: + # Found a matching package version, so include it + return f"pkg:nuget/{package_name}@{package_version}" + else: + # Unknown package version; exclude the version + return f"pkg:nuget/{package_name}" + + return None + + +__nuget = __NuGetManager() + +@surfactant.plugin.hookimpl +def init_hook(command_name: str | None = None): + __nuget.init_urls() + + +@surfactant.plugin.hookimpl +def establish_relationships(sbom: SBOM, software: Software, metadata) -> list[Relationship] | None: + """Checks NuGet for a package name and adds it as a name if it exists""" + + if __nuget.disabled: + return None + + if "dotnetAssembly" not in metadata: + logger.debug(f"[nuget_purl] Skipping: No dotnetAssembly info for NuGet PURL in {software.UUID}") + return None + + for dna in metadata["dotnetAssembly"]: + if purl := __nuget.get_package_url(dna["Name"], dna["Version"]): + if software.name is None: + software.name = [] + software.name.append(NameEntry(purl, "PURL")) From 397ac527065f8154eb84fd476db2dd5475cd60f8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:16:32 +0000 Subject: [PATCH 02/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- surfactant/cmd/generate.py | 4 +--- surfactant/relationships/nuget_purl.py | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/surfactant/cmd/generate.py b/surfactant/cmd/generate.py index d6bcbbdb..f43e8d1f 100644 --- a/surfactant/cmd/generate.py +++ b/surfactant/cmd/generate.py @@ -885,9 +885,7 @@ def sbom( # add "Uses" relationships based on gathered metadata for software entries if not skip_relationships: # Init the relationship hooks here - call_init_hooks( - pm, hook_filter=["establish_relationships"], command_name="generate" - ) + call_init_hooks(pm, hook_filter=["establish_relationships"], command_name="generate") parse_relationships(pm, new_sbom) else: logger.info("Skipping relationships based on imports metadata") diff --git a/surfactant/relationships/nuget_purl.py b/surfactant/relationships/nuget_purl.py index 16335a9b..866f36c6 100644 --- a/surfactant/relationships/nuget_purl.py +++ b/surfactant/relationships/nuget_purl.py @@ -4,11 +4,11 @@ # SPDX-License-Identifier: MIT import requests - from loguru import logger import surfactant.plugin -from surfactant.sbomtypes import SBOM, Relationship, Software, NameEntry +from surfactant.sbomtypes import SBOM, NameEntry, Relationship, Software + class __NuGetManager: def __init__(self): @@ -23,13 +23,14 @@ def init_urls(self): return self.disabled = False - self.package_base_address = [x["@id"] for x in r.json()["resources"] if x["@type"] == "PackageBaseAddress/3.0.0"] + self.package_base_address = [ + x["@id"] for x in r.json()["resources"] if x["@type"] == "PackageBaseAddress/3.0.0" + ] # remove trailing "/" if present for i, pba in enumerate(self.package_base_address): if pba[-1] == "/": self.package_base_address[i] = pba[:-1] - def get_package_url(self, package_name: str, package_version: str) -> str | None: if self.disabled: return None @@ -43,15 +44,15 @@ def get_package_url(self, package_name: str, package_version: str) -> str | None if package_version in versions: # Found a matching package version, so include it return f"pkg:nuget/{package_name}@{package_version}" - else: - # Unknown package version; exclude the version - return f"pkg:nuget/{package_name}" + # Unknown package version; exclude the version + return f"pkg:nuget/{package_name}" return None __nuget = __NuGetManager() + @surfactant.plugin.hookimpl def init_hook(command_name: str | None = None): __nuget.init_urls() @@ -65,7 +66,9 @@ def establish_relationships(sbom: SBOM, software: Software, metadata) -> list[Re return None if "dotnetAssembly" not in metadata: - logger.debug(f"[nuget_purl] Skipping: No dotnetAssembly info for NuGet PURL in {software.UUID}") + logger.debug( + f"[nuget_purl] Skipping: No dotnetAssembly info for NuGet PURL in {software.UUID}" + ) return None for dna in metadata["dotnetAssembly"]: From 4a7ea2f4ca832b4b5b7c90c76ba137291f7b189c Mon Sep 17 00:00:00 2001 From: Kendall Harter Date: Mon, 22 Jun 2026 13:22:12 -0700 Subject: [PATCH 03/11] Address pylint problems --- surfactant/relationships/nuget_purl.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/surfactant/relationships/nuget_purl.py b/surfactant/relationships/nuget_purl.py index 866f36c6..05115d44 100644 --- a/surfactant/relationships/nuget_purl.py +++ b/surfactant/relationships/nuget_purl.py @@ -13,6 +13,7 @@ class __NuGetManager: def __init__(self): self.disabled = True + self.package_base_address = [] def init_urls(self): # Get the base PackageBaseAddress URL @@ -63,13 +64,13 @@ def establish_relationships(sbom: SBOM, software: Software, metadata) -> list[Re """Checks NuGet for a package name and adds it as a name if it exists""" if __nuget.disabled: - return None + return if "dotnetAssembly" not in metadata: logger.debug( f"[nuget_purl] Skipping: No dotnetAssembly info for NuGet PURL in {software.UUID}" ) - return None + return for dna in metadata["dotnetAssembly"]: if purl := __nuget.get_package_url(dna["Name"], dna["Version"]): From 14d23afc1faaa852f2035860b1ad4233c4ca9273 Mon Sep 17 00:00:00 2001 From: Kendall Harter Date: Tue, 23 Jun 2026 10:52:01 -0700 Subject: [PATCH 04/11] Download and check .nuget packages now --- surfactant/relationships/nuget_purl.py | 60 ++++++++++++++++++++------ 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/surfactant/relationships/nuget_purl.py b/surfactant/relationships/nuget_purl.py index 05115d44..16bcd5e9 100644 --- a/surfactant/relationships/nuget_purl.py +++ b/surfactant/relationships/nuget_purl.py @@ -5,6 +5,9 @@ import requests from loguru import logger +import zipfile +import io +import pathlib import surfactant.plugin from surfactant.sbomtypes import SBOM, NameEntry, Relationship, Software @@ -13,7 +16,7 @@ class __NuGetManager: def __init__(self): self.disabled = True - self.package_base_address = [] + self.package_base_addresses = [] def init_urls(self): # Get the base PackageBaseAddress URL @@ -24,29 +27,56 @@ def init_urls(self): return self.disabled = False - self.package_base_address = [ + self.package_base_addresses = [ x["@id"] for x in r.json()["resources"] if x["@type"] == "PackageBaseAddress/3.0.0" ] # remove trailing "/" if present - for i, pba in enumerate(self.package_base_address): + for i, pba in enumerate(self.package_base_addresses): if pba[-1] == "/": - self.package_base_address[i] = pba[:-1] + self.package_base_addresses[i] = pba[:-1] - def get_package_url(self, package_name: str, package_version: str) -> str | None: + def download_nuget(self, package_name: str, package_version: str) -> zipfile.ZipFile | None: + for url in self.package_base_addresses: + pn_low = package_name.lower() + ver_low = package_version.lower() + r = requests.get(f"{url}/{pn_low}/{ver_low}/{pn_low}.{ver_low}.nupkg", stream=True) + if r.status_code != 200: + continue + try: + # For some reason, have to wrap r.raw (a file-like object) + # into an io.BytesIO object to get it to read correctly. + # No idea why. + return zipfile.ZipFile(io.BytesIO(r.raw.read())) + except zipfile.BadZipFile as e: + logger.warning(f"Could not unpack {pn_low}.{ver_low}.nupkg - {e}") + return None + + def file_is_in_package(self, file_name: str, package_name: str, package_version: str) -> bool: + if nuget := self.download_nuget(package_name, package_version): + for f in nuget.infolist(): + if pathlib.Path(f.filename).name == file_name: + return True + return False + + def get_package_url(self, file_name: str, package_name: str, package_version: str) -> str | None: if self.disabled: return None - for url in self.package_base_address: + for url in self.package_base_addresses: r = requests.get(f"{url}/{package_name.lower()}/index.json") if r.status_code != 200: continue if versions := r.json()["versions"]: if package_version in versions: - # Found a matching package version, so include it - return f"pkg:nuget/{package_name}@{package_version}" - # Unknown package version; exclude the version - return f"pkg:nuget/{package_name}" + # Found a matching package version, check that specific version + if self.file_is_in_package(file_name, package_name, package_version): + return f"pkg:nuget/{package_name}@{package_version}" + else: + # Unknown package version; check the latest package version + latest_version = versions[-1] + if self.file_is_in_package(file_name, package_name, latest_version): + return f"pkg:nuget/{package_name}" return None @@ -73,7 +103,9 @@ def establish_relationships(sbom: SBOM, software: Software, metadata) -> list[Re return for dna in metadata["dotnetAssembly"]: - if purl := __nuget.get_package_url(dna["Name"], dna["Version"]): - if software.name is None: - software.name = [] - software.name.append(NameEntry(purl, "PURL")) + if software.fileName: + for name in software.fileName: + if purl := __nuget.get_package_url(name, dna["Name"], dna["Version"]): + if software.name is None: + software.name = [] + software.name.append(NameEntry(purl, "PURL")) From ca162e6bcceaca7ad633378ba01da7e9bdc7738c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:52:37 +0000 Subject: [PATCH 05/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- surfactant/relationships/nuget_purl.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/surfactant/relationships/nuget_purl.py b/surfactant/relationships/nuget_purl.py index 16bcd5e9..d0272a82 100644 --- a/surfactant/relationships/nuget_purl.py +++ b/surfactant/relationships/nuget_purl.py @@ -3,11 +3,12 @@ # # SPDX-License-Identifier: MIT -import requests -from loguru import logger -import zipfile import io import pathlib +import zipfile + +import requests +from loguru import logger import surfactant.plugin from surfactant.sbomtypes import SBOM, NameEntry, Relationship, Software @@ -58,7 +59,9 @@ def file_is_in_package(self, file_name: str, package_name: str, package_version: return True return False - def get_package_url(self, file_name: str, package_name: str, package_version: str) -> str | None: + def get_package_url( + self, file_name: str, package_name: str, package_version: str + ) -> str | None: if self.disabled: return None From 40f0e685e6a4fce672c9f8e80172276465299cbb Mon Sep 17 00:00:00 2001 From: Kendall Harter <66640839+KendallHarterAtWork@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:34:14 -0700 Subject: [PATCH 06/11] Remove duplicate requests from dependencies --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index efa7b863..84d82a1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,6 @@ dependencies = [ "python-msi==0.0.0b3", # Pinned to specific version for potential breaking changes "rpmfile==2.2.1", - "requests==2.*", ] dynamic = ["version"] From 4d5591e87fc4ccb8e6ab75a1a197fc0e55b575b2 Mon Sep 17 00:00:00 2001 From: Kendall Harter Date: Mon, 6 Jul 2026 11:18:00 -0700 Subject: [PATCH 07/11] Adjust based on PR comments --- surfactant/relationships/nuget_purl.py | 144 +++++++++++++++++++------ 1 file changed, 109 insertions(+), 35 deletions(-) diff --git a/surfactant/relationships/nuget_purl.py b/surfactant/relationships/nuget_purl.py index d0272a82..62bbe9d3 100644 --- a/surfactant/relationships/nuget_purl.py +++ b/surfactant/relationships/nuget_purl.py @@ -3,6 +3,14 @@ # # SPDX-License-Identifier: MIT +""" +Config Options: + enable_lookups(bool): + Enable NuGet network requests, default is False. + request_timeout(int): + Time to wait for a NuGet network response in seconds, default is 30. +""" + import io import pathlib import zipfile @@ -11,25 +19,41 @@ from loguru import logger import surfactant.plugin +from surfactant.configmanager import ConfigManager from surfactant.sbomtypes import SBOM, NameEntry, Relationship, Software -class __NuGetManager: +class _NuGetManager: def __init__(self): self.disabled = True self.package_base_addresses = [] + config_manager = ConfigManager() + self.request_timeout = float(config_manager.get("nuget", "request_timeout", 30.0)) + + # package_name.lower() -> version list, or None if the package wasn't + # found. None is cached to avoid re-querying misses. + self._index_cache: dict[str, list[str] | None] = {} + + # (package_name.lower(), version.lower()) -> set of nupkg member basenames. + # An empty set means download failed/not found; cached so the same + # nupkg is never re-downloaded during an enrichment pass. + self._members_cache: dict[tuple[str, str], set[str]] = {} + def init_urls(self): # Get the base PackageBaseAddress URL - r = requests.get("https://api.nuget.org/v3/index.json") - if r.status_code != 200: - logger.warning(f"NuGet API returned {r.status_code}; disabling") + try: + r = requests.get("https://api.nuget.org/v3/index.json", timeout=self.request_timeout) + r.raise_for_status() + resources = r.json()["resources"] + except (requests.RequestException, ValueError, KeyError) as e: + logger.warning(f"NuGet API unavailable ({e}); disabling NuGet lookups") self.disabled = True return self.disabled = False self.package_base_addresses = [ - x["@id"] for x in r.json()["resources"] if x["@type"] == "PackageBaseAddress/3.0.0" + x["@id"] for x in resources if x["@type"] == "PackageBaseAddress/3.0.0" ] # remove trailing "/" if present for i, pba in enumerate(self.package_base_addresses): @@ -37,27 +61,64 @@ def init_urls(self): self.package_base_addresses[i] = pba[:-1] def download_nuget(self, package_name: str, package_version: str) -> zipfile.ZipFile | None: + pn_low = package_name.lower() + ver_low = package_version.lower() for url in self.package_base_addresses: - pn_low = package_name.lower() - ver_low = package_version.lower() - r = requests.get(f"{url}/{pn_low}/{ver_low}/{pn_low}.{ver_low}.nupkg", stream=True) + try: + r = requests.get( + f"{url}/{pn_low}/{ver_low}/{pn_low}.{ver_low}.nupkg", + stream=True, + timeout=self.request_timeout + ) + except requests.RequestException as e: + logger.warning(f"NuGet download failed for {pn_low}.{ver_low}.nupkg - {e}") + continue if r.status_code != 200: continue try: - # For some reason, have to wrap r.raw (a file-like object) - # into an io.BytesIO object to get it to read correctly. - # No idea why. return zipfile.ZipFile(io.BytesIO(r.raw.read())) except zipfile.BadZipFile as e: logger.warning(f"Could not unpack {pn_low}.{ver_low}.nupkg - {e}") return None def file_is_in_package(self, file_name: str, package_name: str, package_version: str) -> bool: - if nuget := self.download_nuget(package_name, package_version): - for f in nuget.infolist(): - if pathlib.Path(f.filename).name == file_name: - return True - return False + key = (package_name.lower(), package_version.lower()) + if key not in self._members_cache: + members: set[str] = set() + if nuget := self.download_nuget(package_name, package_version): + members = {pathlib.Path(f.filename).name.lower() for f in nuget.infolist()} + # Cache even an empty set so a failed/missing download isn't retried. + self._members_cache[key] = members + return file_name.lower() in self._members_cache[key] + + def _get_versions(self, package_name: str) -> list[str] | None: + """Returns a package's version list, caching the result. + + Queries each PackageBaseAddress until one serves the package's + index.json. The result - including a negative None - is cached per + package name, so repeated files from the same package cost one request. + """ + key = package_name.lower() + if key in self._index_cache: + return self._index_cache[key] + + versions: list[str] | None = None + for url in self.package_base_addresses: + try: + r = requests.get(f"{url}/{key}/index.json", timeout=self.request_timeout) + if r.status_code != 200: + continue + + versions = r.json().get("versions") or None + except (requests.RequestException, ValueError) as e: + logger.warning(f"NuGet index lookup failed for {key} - {e}") + continue + + if versions: + break + + self._index_cache[key] = versions + return versions def get_package_url( self, file_name: str, package_name: str, package_version: str @@ -65,41 +126,50 @@ def get_package_url( if self.disabled: return None - for url in self.package_base_addresses: - r = requests.get(f"{url}/{package_name.lower()}/index.json") - if r.status_code != 200: - continue + versions = self._get_versions(package_name) + if not versions: + return None - if versions := r.json()["versions"]: - if package_version in versions: - # Found a matching package version, check that specific version - if self.file_is_in_package(file_name, package_name, package_version): - return f"pkg:nuget/{package_name}@{package_version}" - else: - # Unknown package version; check the latest package version - latest_version = versions[-1] - if self.file_is_in_package(file_name, package_name, latest_version): - return f"pkg:nuget/{package_name}" + if package_name in versions: + # Found a matching package version, check that specific version + if self.file_is_in_package(file_name, package_name, package_version): + return f"pkg:nuget/{package_name}@{package_version}" + else: + # Unknown package version; check the latest stable package version if available + stable = [v for v in versions if "-" not in v] or versions + latest_version = stable[-1] + if self.file_is_in_package(file_name, package_name, latest_version): + return f"pkg:nuget/{package_name}" return None -__nuget = __NuGetManager() +_nuget = _NuGetManager() @surfactant.plugin.hookimpl def init_hook(command_name: str | None = None): - __nuget.init_urls() + if command_name != "generate": + return + + if not ConfigManager().get("nuget", "enable_lookups", False): + logger.info( + "[nuget_purl] NuGet lookups disabled via config (nuget.enable_lookups=false)" + ) + _nuget.disabled = True + return + + _nuget.init_urls() @surfactant.plugin.hookimpl def establish_relationships(sbom: SBOM, software: Software, metadata) -> list[Relationship] | None: """Checks NuGet for a package name and adds it as a name if it exists""" - if __nuget.disabled: + if _nuget.disabled: return - if "dotnetAssembly" not in metadata: + if not isinstance(metadata, dict) or "dotnetAssembly" not in metadata: logger.debug( f"[nuget_purl] Skipping: No dotnetAssembly info for NuGet PURL in {software.UUID}" ) @@ -108,7 +178,11 @@ def establish_relationships(sbom: SBOM, software: Software, metadata) -> list[Re for dna in metadata["dotnetAssembly"]: if software.fileName: for name in software.fileName: - if purl := __nuget.get_package_url(name, dna["Name"], dna["Version"]): + if purl := _nuget.get_package_url(name, dna["Name"], dna["Version"]): if software.name is None: software.name = [] software.name.append(NameEntry(purl, "PURL")) + +@surfactant.plugin.hookimpl +def settings_name() -> str | None: + return "nuget" From 9abeef6db2fd20e471e89a28448abd0d944e0fc9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:18:24 +0000 Subject: [PATCH 08/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- surfactant/relationships/nuget_purl.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/surfactant/relationships/nuget_purl.py b/surfactant/relationships/nuget_purl.py index 62bbe9d3..146778f3 100644 --- a/surfactant/relationships/nuget_purl.py +++ b/surfactant/relationships/nuget_purl.py @@ -68,7 +68,7 @@ def download_nuget(self, package_name: str, package_version: str) -> zipfile.Zip r = requests.get( f"{url}/{pn_low}/{ver_low}/{pn_low}.{ver_low}.nupkg", stream=True, - timeout=self.request_timeout + timeout=self.request_timeout, ) except requests.RequestException as e: logger.warning(f"NuGet download failed for {pn_low}.{ver_low}.nupkg - {e}") @@ -153,9 +153,7 @@ def init_hook(command_name: str | None = None): return if not ConfigManager().get("nuget", "enable_lookups", False): - logger.info( - "[nuget_purl] NuGet lookups disabled via config (nuget.enable_lookups=false)" - ) + logger.info("[nuget_purl] NuGet lookups disabled via config (nuget.enable_lookups=false)") _nuget.disabled = True return @@ -183,6 +181,7 @@ def establish_relationships(sbom: SBOM, software: Software, metadata) -> list[Re software.name = [] software.name.append(NameEntry(purl, "PURL")) + @surfactant.plugin.hookimpl def settings_name() -> str | None: return "nuget" From eee03d8b57c0ed4114b5d7b4c6c573cee4aa3a92 Mon Sep 17 00:00:00 2001 From: Kendall Harter Date: Mon, 6 Jul 2026 11:42:34 -0700 Subject: [PATCH 09/11] Remove configuring request timeout until TUI support for config numbers is added --- surfactant/relationships/nuget_purl.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/surfactant/relationships/nuget_purl.py b/surfactant/relationships/nuget_purl.py index 146778f3..164b23cc 100644 --- a/surfactant/relationships/nuget_purl.py +++ b/surfactant/relationships/nuget_purl.py @@ -7,10 +7,12 @@ Config Options: enable_lookups(bool): Enable NuGet network requests, default is False. - request_timeout(int): - Time to wait for a NuGet network response in seconds, default is 30. """ +# Don't have this as a configuration until the TUI supports int/float +# request_timeout(int): +# Time to wait for a NuGet network response in seconds, default is 30. + import io import pathlib import zipfile @@ -28,8 +30,8 @@ def __init__(self): self.disabled = True self.package_base_addresses = [] - config_manager = ConfigManager() - self.request_timeout = float(config_manager.get("nuget", "request_timeout", 30.0)) + # self.request_timeout = float(ConfigManager().get("nuget", "request_timeout", 30.0)) + self.request_timeout = 30 # package_name.lower() -> version list, or None if the package wasn't # found. None is cached to avoid re-querying misses. From 7b4e49bf98e4c88b5a8351d2343a1165aa8520ae Mon Sep 17 00:00:00 2001 From: Ryan Mast Date: Mon, 20 Jul 2026 17:08:50 -0700 Subject: [PATCH 10/11] Fix some minor bugs --- surfactant/relationships/nuget_purl.py | 27 +++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/surfactant/relationships/nuget_purl.py b/surfactant/relationships/nuget_purl.py index 164b23cc..99bb6a87 100644 --- a/surfactant/relationships/nuget_purl.py +++ b/surfactant/relationships/nuget_purl.py @@ -123,19 +123,27 @@ def _get_versions(self, package_name: str) -> list[str] | None: return versions def get_package_url( - self, file_name: str, package_name: str, package_version: str + self, file_name: str, package_name: str, package_version: str | list[str] ) -> str | None: if self.disabled: return None + if package_name is None: + return None + versions = self._get_versions(package_name) if not versions: return None - if package_name in versions: + if isinstance(package_version, str): + candidate_versions = [package_version] + else: + candidate_versions = package_version + + if found_version := next((v for v in candidate_versions if v in versions), None): # Found a matching package version, check that specific version - if self.file_is_in_package(file_name, package_name, package_version): - return f"pkg:nuget/{package_name}@{package_version}" + if self.file_is_in_package(file_name, package_name, found_version): + return f"pkg:nuget/{package_name}@{found_version}" else: # Unknown package version; check the latest stable package version if available stable = [v for v in versions if "-" not in v] or versions @@ -175,13 +183,18 @@ def establish_relationships(sbom: SBOM, software: Software, metadata) -> list[Re ) return - for dna in metadata["dotnetAssembly"]: + # From real samples, found that "FileInfo" "ProductVersion" is often better than "dotnetAssembly" + # "Version" values (could search for that as a fallback if desired); ideal might be to have an + # option to turn "ProductName" file info field into a NuGet package name + fi = metadata.get("FileInfo", {}) + product_version = fi.get("ProductVersion", "").split('+', 1)[0] + for dna in metadata.get("dotnetAssembly"): if software.fileName: for name in software.fileName: - if purl := _nuget.get_package_url(name, dna["Name"], dna["Version"]): + if purl := _nuget.get_package_url(name, dna.get("Name"), product_version): if software.name is None: software.name = [] - software.name.append(NameEntry(purl, "PURL")) + software.name.append(NameEntry(purl, "package URL (purl)")) @surfactant.plugin.hookimpl From f852cd35bb05c8fb527521c4a5cefdca54f2bcff Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:24:19 +0000 Subject: [PATCH 11/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- surfactant/relationships/nuget_purl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfactant/relationships/nuget_purl.py b/surfactant/relationships/nuget_purl.py index 99bb6a87..f7a54441 100644 --- a/surfactant/relationships/nuget_purl.py +++ b/surfactant/relationships/nuget_purl.py @@ -187,7 +187,7 @@ def establish_relationships(sbom: SBOM, software: Software, metadata) -> list[Re # "Version" values (could search for that as a fallback if desired); ideal might be to have an # option to turn "ProductName" file info field into a NuGet package name fi = metadata.get("FileInfo", {}) - product_version = fi.get("ProductVersion", "").split('+', 1)[0] + product_version = fi.get("ProductVersion", "").split("+", 1)[0] for dna in metadata.get("dotnetAssembly"): if software.fileName: for name in software.fileName: