From ef666790d740785a2daf691fb550678a51bff712 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Wed, 5 Nov 2025 09:59:42 +0100 Subject: [PATCH 01/14] Group poetry.lock and pyproject.toml in frozen dataclass --- exasol/toolbox/nox/_lint.py | 3 ++- exasol/toolbox/nox/_release.py | 3 ++- .../util/dependencies/poetry_dependencies.py | 18 ++++++++++++++---- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/exasol/toolbox/nox/_lint.py b/exasol/toolbox/nox/_lint.py index 890839a10..ff4d6c711 100644 --- a/exasol/toolbox/nox/_lint.py +++ b/exasol/toolbox/nox/_lint.py @@ -11,6 +11,7 @@ from nox import Session from exasol.toolbox.nox._shared import python_files +from exasol.toolbox.util.dependencies.poetry_dependencies import PoetryFiles from noxconfig import PROJECT_CONFIG @@ -140,7 +141,7 @@ def security_lint(session: Session) -> None: @nox.session(name="lint:dependencies", python=False) def dependency_check(session: Session) -> None: """Checks if only valid sources of dependencies are used""" - content = Path(PROJECT_CONFIG.root, "pyproject.toml").read_text() + content = Path(PROJECT_CONFIG.root, PoetryFiles.pyproject_toml).read_text() dependencies = Dependencies.parse(content) console = rich.console.Console() if illegal := dependencies.illegal: diff --git a/exasol/toolbox/nox/_release.py b/exasol/toolbox/nox/_release.py index 5b86fbe1c..ce597899c 100644 --- a/exasol/toolbox/nox/_release.py +++ b/exasol/toolbox/nox/_release.py @@ -14,6 +14,7 @@ check_for_config_attribute, ) from exasol.toolbox.nox.plugin import NoxTasks +from exasol.toolbox.util.dependencies.poetry_dependencies import PoetryFiles from exasol.toolbox.util.git import Git from exasol.toolbox.util.release.changelog import Changelogs from exasol.toolbox.util.version import ( @@ -142,7 +143,7 @@ def prepare_release(session: Session) -> None: return changed_files += [ - PROJECT_CONFIG.root / "pyproject.toml", + PROJECT_CONFIG.root / PoetryFiles.pyproject_toml, PROJECT_CONFIG.version_file, ] results = pm.hook.prepare_release_add_files(session=session, config=PROJECT_CONFIG) diff --git a/exasol/toolbox/util/dependencies/poetry_dependencies.py b/exasol/toolbox/util/dependencies/poetry_dependencies.py index b801e4b39..d717ca2e6 100644 --- a/exasol/toolbox/util/dependencies/poetry_dependencies.py +++ b/exasol/toolbox/util/dependencies/poetry_dependencies.py @@ -3,8 +3,9 @@ import subprocess import tempfile from collections import OrderedDict +from dataclasses import dataclass from pathlib import Path -from typing import Optional +from typing import Final import tomlkit from pydantic import ( @@ -28,7 +29,16 @@ class PoetryGroup(BaseModel): toml_section: str | None -PYPROJECT_TOML = "pyproject.toml" +@dataclass(frozen=True) +class PoetryFiles: + pyproject_toml: Final[str] = "pyproject.toml" + poetry_lock: Final[str] = "poetry.lock" + + @property + def files(self) -> tuple[str, ...]: + return tuple(self.__dict__.values()) + + TRANSITIVE_GROUP = PoetryGroup(name="transitive", toml_section=None) @@ -39,7 +49,7 @@ class PoetryToml(BaseModel): @classmethod def load_from_toml(cls, working_directory: Path) -> PoetryToml: - file_path = working_directory / PYPROJECT_TOML + file_path = working_directory / PoetryFiles.pyproject_toml if not file_path.exists(): raise ValueError(f"File not found: {file_path}") @@ -169,6 +179,6 @@ def get_dependencies_from_latest_tag() -> ( path = PROJECT_CONFIG.root.relative_to(Git.toplevel()) with tempfile.TemporaryDirectory() as tmpdir_str: tmpdir = Path(tmpdir_str) - for file in ("poetry.lock", PYPROJECT_TOML): + for file in PoetryFiles().files: Git.checkout(latest_tag, path / file, tmpdir / file) return get_dependencies(working_directory=tmpdir) From 61bb81bd51bdafaa96232eead996c281e431570c Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Wed, 5 Nov 2025 10:11:38 +0100 Subject: [PATCH 02/14] Move PoetryFiles and create poetry_files_from_latest_tag in shared_models. Add get_vulnerabilities_from_latest_tag --- exasol/toolbox/nox/_lint.py | 2 +- exasol/toolbox/nox/_release.py | 2 +- exasol/toolbox/util/dependencies/audit.py | 11 +++++-- .../util/dependencies/poetry_dependencies.py | 26 +++------------- .../util/dependencies/shared_models.py | 31 +++++++++++++++++++ 5 files changed, 46 insertions(+), 26 deletions(-) diff --git a/exasol/toolbox/nox/_lint.py b/exasol/toolbox/nox/_lint.py index ff4d6c711..b8d74f201 100644 --- a/exasol/toolbox/nox/_lint.py +++ b/exasol/toolbox/nox/_lint.py @@ -11,7 +11,7 @@ from nox import Session from exasol.toolbox.nox._shared import python_files -from exasol.toolbox.util.dependencies.poetry_dependencies import PoetryFiles +from exasol.toolbox.util.dependencies.shared_models import PoetryFiles from noxconfig import PROJECT_CONFIG diff --git a/exasol/toolbox/nox/_release.py b/exasol/toolbox/nox/_release.py index ce597899c..cf476a512 100644 --- a/exasol/toolbox/nox/_release.py +++ b/exasol/toolbox/nox/_release.py @@ -14,7 +14,7 @@ check_for_config_attribute, ) from exasol.toolbox.nox.plugin import NoxTasks -from exasol.toolbox.util.dependencies.poetry_dependencies import PoetryFiles +from exasol.toolbox.util.dependencies.shared_models import PoetryFiles from exasol.toolbox.util.git import Git from exasol.toolbox.util.release.changelog import Changelogs from exasol.toolbox.util.version import ( diff --git a/exasol/toolbox/util/dependencies/audit.py b/exasol/toolbox/util/dependencies/audit.py index 6cdd75aa6..6299332ac 100644 --- a/exasol/toolbox/util/dependencies/audit.py +++ b/exasol/toolbox/util/dependencies/audit.py @@ -8,12 +8,14 @@ from re import search from typing import ( Any, - Union, ) from pydantic import BaseModel -from exasol.toolbox.util.dependencies.shared_models import Package +from exasol.toolbox.util.dependencies.shared_models import ( + Package, + poetry_files_from_latest_tag, +) PIP_AUDIT_VULNERABILITY_PATTERN = ( r"^Found \d+ known vulnerabilit\w{1,3} in \d+ package\w?$" @@ -145,3 +147,8 @@ def security_issue_dict(self) -> list[dict[str, str | list[str]]]: return [ vulnerability.security_issue_entry for vulnerability in self.vulnerabilities ] + + +def get_vulnerabilities_from_latest_tag(): + with poetry_files_from_latest_tag() as tmp_dir: + return Vulnerabilities.load_from_pip_audit(working_directory=tmp_dir) diff --git a/exasol/toolbox/util/dependencies/poetry_dependencies.py b/exasol/toolbox/util/dependencies/poetry_dependencies.py index d717ca2e6..222159778 100644 --- a/exasol/toolbox/util/dependencies/poetry_dependencies.py +++ b/exasol/toolbox/util/dependencies/poetry_dependencies.py @@ -1,11 +1,8 @@ from __future__ import annotations import subprocess -import tempfile from collections import OrderedDict -from dataclasses import dataclass from pathlib import Path -from typing import Final import tomlkit from pydantic import ( @@ -17,9 +14,9 @@ from exasol.toolbox.util.dependencies.shared_models import ( NormalizedPackageStr, Package, + PoetryFiles, + poetry_files_from_latest_tag, ) -from exasol.toolbox.util.git import Git -from noxconfig import PROJECT_CONFIG class PoetryGroup(BaseModel): @@ -29,16 +26,6 @@ class PoetryGroup(BaseModel): toml_section: str | None -@dataclass(frozen=True) -class PoetryFiles: - pyproject_toml: Final[str] = "pyproject.toml" - poetry_lock: Final[str] = "poetry.lock" - - @property - def files(self) -> tuple[str, ...]: - return tuple(self.__dict__.values()) - - TRANSITIVE_GROUP = PoetryGroup(name="transitive", toml_section=None) @@ -175,10 +162,5 @@ def get_dependencies( def get_dependencies_from_latest_tag() -> ( OrderedDict[str, dict[NormalizedPackageStr, Package]] ): - latest_tag = Git.get_latest_tag() - path = PROJECT_CONFIG.root.relative_to(Git.toplevel()) - with tempfile.TemporaryDirectory() as tmpdir_str: - tmpdir = Path(tmpdir_str) - for file in PoetryFiles().files: - Git.checkout(latest_tag, path / file, tmpdir / file) - return get_dependencies(working_directory=tmpdir) + with poetry_files_from_latest_tag() as tmp_dir: + return get_dependencies(working_directory=tmp_dir) diff --git a/exasol/toolbox/util/dependencies/shared_models.py b/exasol/toolbox/util/dependencies/shared_models.py index a4df1e699..75d2e928e 100644 --- a/exasol/toolbox/util/dependencies/shared_models.py +++ b/exasol/toolbox/util/dependencies/shared_models.py @@ -1,7 +1,13 @@ from __future__ import annotations +import tempfile +from collections.abc import Generator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path from typing import ( Annotated, + Final, NewType, ) @@ -12,6 +18,9 @@ ConfigDict, ) +from exasol.toolbox.util.git import Git +from noxconfig import PROJECT_CONFIG + NormalizedPackageStr = NewType("NormalizedPackageStr", str) VERSION_TYPE = Annotated[str, AfterValidator(lambda v: Version(v))] @@ -30,3 +39,25 @@ class Package(BaseModel): @property def normalized_name(self) -> NormalizedPackageStr: return normalize_package_name(self.name) + + +@dataclass(frozen=True) +class PoetryFiles: + pyproject_toml: Final[str] = "pyproject.toml" + poetry_lock: Final[str] = "poetry.lock" + + @property + def files(self) -> tuple[str, ...]: + return tuple(self.__dict__.values()) + + +@contextmanager +def poetry_files_from_latest_tag() -> Generator[Path]: + """Context manager to set up a temporary directory with poetry files from the latest tag""" + latest_tag = Git.get_latest_tag() + path = PROJECT_CONFIG.root.relative_to(Git.toplevel()) + with tempfile.TemporaryDirectory() as tmpdir_str: + tmp_dir = Path(tmpdir_str) + for file in PoetryFiles().files: + Git.checkout(latest_tag, path / file, tmp_dir / file) + yield tmp_dir From 5d9f12425b39d9789bf5fbf5ed4ad639fb369a0e Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Wed, 5 Nov 2025 10:25:22 +0100 Subject: [PATCH 03/14] Add test for poetry_files_from_latest_tag --- test/unit/util/dependencies/shared_models_test.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/unit/util/dependencies/shared_models_test.py b/test/unit/util/dependencies/shared_models_test.py index e16b78fc9..a768250d1 100644 --- a/test/unit/util/dependencies/shared_models_test.py +++ b/test/unit/util/dependencies/shared_models_test.py @@ -6,7 +6,10 @@ from exasol.toolbox.util.dependencies.shared_models import ( VERSION_TYPE, Package, + PoetryFiles, + poetry_files_from_latest_tag, ) +from exasol.toolbox.util.git import Git class Dummy(BaseModel): @@ -45,3 +48,13 @@ class TestPackage: def test_normalized_name(name, expected): dep = Package(name=name, version="0.1.0") assert dep.normalized_name == expected + + +def test_poetry_files_from_latest_tag(): + latest_tag = Git.get_latest_tag() + with poetry_files_from_latest_tag() as tmp_dir: + for file in PoetryFiles().files: + assert (tmp_dir / file).is_file() + + contents = (tmp_dir / PoetryFiles.pyproject_toml).read_text() + assert f'version = "{latest_tag}"' in contents From 611d275212cbda03bf4dba6ca898cf8d76917991 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Wed, 5 Nov 2025 10:40:32 +0100 Subject: [PATCH 04/14] Add basic unit tests for get_vulnerabilities and get_vulnerabilities_from_latest_tag --- exasol/toolbox/util/dependencies/audit.py | 8 ++++++- test/unit/util/dependencies/audit_test.py | 29 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/exasol/toolbox/util/dependencies/audit.py b/exasol/toolbox/util/dependencies/audit.py index 6299332ac..3700acac8 100644 --- a/exasol/toolbox/util/dependencies/audit.py +++ b/exasol/toolbox/util/dependencies/audit.py @@ -149,6 +149,12 @@ def security_issue_dict(self) -> list[dict[str, str | list[str]]]: ] +def get_vulnerabilities(working_directory: Path) -> list[Vulnerability]: + return Vulnerabilities.load_from_pip_audit( + working_directory=working_directory + ).vulnerabilities + + def get_vulnerabilities_from_latest_tag(): with poetry_files_from_latest_tag() as tmp_dir: - return Vulnerabilities.load_from_pip_audit(working_directory=tmp_dir) + return get_vulnerabilities(working_directory=tmp_dir) diff --git a/test/unit/util/dependencies/audit_test.py b/test/unit/util/dependencies/audit_test.py index 6b50fe98a..b23cad480 100644 --- a/test/unit/util/dependencies/audit_test.py +++ b/test/unit/util/dependencies/audit_test.py @@ -11,7 +11,10 @@ Vulnerabilities, Vulnerability, audit_poetry_files, + get_vulnerabilities, + get_vulnerabilities_from_latest_tag, ) +from noxconfig import PROJECT_CONFIG @pytest.fixture @@ -133,3 +136,29 @@ def test_security_issue_dict(sample_vulnerability): ) result = vulnerabilities.security_issue_dict assert result == [sample_vulnerability.security_issue_entry] + + +class TestGetVulnerabilities: + def test_with_mock(self, sample_vulnerability): + with mock.patch( + "exasol.toolbox.util.dependencies.audit.audit_poetry_files", + return_value=sample_vulnerability.pip_audit_json, + ): + result = get_vulnerabilities(PROJECT_CONFIG.root) + + # if successful, no errors & should be 1 due to mock + assert isinstance(result, list) + assert len(result) == 1 + + +class TestGetVulnerabilitiesFromLatestTag: + def test_with_mock(self, sample_vulnerability): + with mock.patch( + "exasol.toolbox.util.dependencies.audit.audit_poetry_files", + return_value=sample_vulnerability.pip_audit_json, + ): + result = get_vulnerabilities_from_latest_tag() + + # if successful, no errors & should be 1 due to mock + assert isinstance(result, list) + assert len(result) == 1 From a76f38439543aa0658ab930801577a3953fbe6b1 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Wed, 5 Nov 2025 12:29:41 +0100 Subject: [PATCH 05/14] Add ResolvedVulnerabilities to detect if vulnerability has been resolved from previous work to the present --- .../dependencies/track_vulnerabilities.py | 43 +++++++++++++++ .../track_vulnerabilities_test.py | 55 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 exasol/toolbox/util/dependencies/track_vulnerabilities.py create mode 100644 test/unit/util/dependencies/track_vulnerabilities_test.py diff --git a/exasol/toolbox/util/dependencies/track_vulnerabilities.py b/exasol/toolbox/util/dependencies/track_vulnerabilities.py new file mode 100644 index 000000000..09e60580f --- /dev/null +++ b/exasol/toolbox/util/dependencies/track_vulnerabilities.py @@ -0,0 +1,43 @@ +from pydantic import ( + BaseModel, + ConfigDict, +) + +from exasol.toolbox.util.dependencies.audit import Vulnerability + + +class ResolvedVulnerabilities(BaseModel): + model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) + + previous_vulnerabilities: list[Vulnerability] + current_vulnerabilities: list[Vulnerability] + + def _is_resolved(self, previous_vuln: Vulnerability): + """ + Detects if a vulnerability has been resolved. + + A vulnerability is said to be resolved when it cannot be found + in the `current_vulnerabilities`. In order to see if a vulnerability + is still present, its id and aliases are compared to values in the + `current_vulnerabilities`. While one could hope that the IDs + are the same, it's possible between pip-audit versions that + there are differences. + """ + previous_vuln_set = {previous_vuln.id, *previous_vuln.aliases} + for current_vuln in self.current_vulnerabilities: + if previous_vuln.name == current_vuln.name: + current_vuln_id_set = {current_vuln.id, *current_vuln.aliases} + if previous_vuln_set.intersection(current_vuln_id_set): + return False + return True + + @property + def resolutions(self) -> list[Vulnerability]: + """ + Return resolved vulnerabilities + """ + resolved_vulnerabilities = [] + for previous_vuln in self.previous_vulnerabilities: + if self._is_resolved(previous_vuln): + resolved_vulnerabilities.append(previous_vuln) + return resolved_vulnerabilities diff --git a/test/unit/util/dependencies/track_vulnerabilities_test.py b/test/unit/util/dependencies/track_vulnerabilities_test.py new file mode 100644 index 000000000..1dd584ac9 --- /dev/null +++ b/test/unit/util/dependencies/track_vulnerabilities_test.py @@ -0,0 +1,55 @@ +from exasol.toolbox.util.dependencies.track_vulnerabilities import ( + ResolvedVulnerabilities, +) + + +class TestResolvedVulnerabilities: + def test_vulnerability_present_for_previous_and_current(self, sample_vulnerability): + vuln = sample_vulnerability.vulnerability + resolved = ResolvedVulnerabilities( + previous_vulnerabilities=[vuln], current_vulnerabilities=[vuln] + ) + assert resolved._is_resolved(vuln) is False + + def test_vulnerability_present_for_previous_and_current_with_different_id( + self, sample_vulnerability + ): + vuln2 = sample_vulnerability.vulnerability.__dict__.copy() + vuln2["version"] = sample_vulnerability.version + # flipping aliases & id to ensure can match across types + vuln2["aliases"] = [sample_vulnerability.vulnerability_id] + vuln2["id"] = sample_vulnerability.cve_id + + resolved = ResolvedVulnerabilities( + previous_vulnerabilities=[sample_vulnerability.vulnerability], + current_vulnerabilities=[vuln2], + ) + assert resolved._is_resolved(sample_vulnerability.vulnerability) is False + + def test_vulnerability_in_previous_resolved_in_current(self, sample_vulnerability): + vuln = sample_vulnerability.vulnerability + resolved = ResolvedVulnerabilities( + previous_vulnerabilities=[vuln], current_vulnerabilities=[] + ) + assert resolved._is_resolved(vuln) is True + + def test_no_vulnerabilities_for_previous_and_current(self): + resolved = ResolvedVulnerabilities( + previous_vulnerabilities=[], current_vulnerabilities=[] + ) + assert resolved.resolutions == [] + + def test_vulnerability_in_current_but_not_present(self, sample_vulnerability): + resolved = ResolvedVulnerabilities( + previous_vulnerabilities=[], + current_vulnerabilities=[sample_vulnerability.vulnerability], + ) + # only care about "resolved" vulnerabilities, not new ones + assert resolved.resolutions == [] + + def test_resolved_vulnerabilities(self, sample_vulnerability): + resolved = ResolvedVulnerabilities( + previous_vulnerabilities=[sample_vulnerability.vulnerability], + current_vulnerabilities=[], + ) + assert resolved.resolutions == [sample_vulnerability.vulnerability] From 543620eb7d84fbddbaa1735c579da85afb0971d3 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Wed, 5 Nov 2025 14:44:17 +0100 Subject: [PATCH 06/14] Add coordinates --- exasol/toolbox/tools/security.py | 2 +- exasol/toolbox/util/dependencies/audit.py | 1 + exasol/toolbox/util/dependencies/shared_models.py | 11 +++++++++++ exasol/toolbox/util/dependencies/track_changes.py | 15 +++++++-------- test/conftest.py | 1 + test/unit/util/dependencies/shared_models_test.py | 5 +++++ 6 files changed, 26 insertions(+), 9 deletions(-) diff --git a/exasol/toolbox/tools/security.py b/exasol/toolbox/tools/security.py index 18e45dad7..7038a33b3 100644 --- a/exasol/toolbox/tools/security.py +++ b/exasol/toolbox/tools/security.py @@ -186,7 +186,7 @@ def from_pip_audit(report: str) -> Iterable[Issue]: cve=sorted(cves)[0], cwe="None" if not cwes else ", ".join(cwes), description=vulnerability["description"], - coordinates=f"{vulnerability['name']}:{vulnerability['version']}", + coordinates=vulnerability["coordinates"], references=tuple(links), ) diff --git a/exasol/toolbox/util/dependencies/audit.py b/exasol/toolbox/util/dependencies/audit.py index 3700acac8..c33b53ebd 100644 --- a/exasol/toolbox/util/dependencies/audit.py +++ b/exasol/toolbox/util/dependencies/audit.py @@ -63,6 +63,7 @@ def security_issue_entry(self) -> dict[str, str | list[str]]: "version": str(self.version), "refs": [self.id] + self.aliases, "description": self.description, + "coordinates": self.coordinates, } diff --git a/exasol/toolbox/util/dependencies/shared_models.py b/exasol/toolbox/util/dependencies/shared_models.py index 75d2e928e..9637d5177 100644 --- a/exasol/toolbox/util/dependencies/shared_models.py +++ b/exasol/toolbox/util/dependencies/shared_models.py @@ -30,12 +30,23 @@ def normalize_package_name(package_name: str) -> NormalizedPackageStr: return NormalizedPackageStr(package_name.lower().replace("_", "-")) +def create_coordinates(package_name: str, version: str | Version) -> str: + """ + Create a naming convention for combining a package name and its version + """ + return f"{package_name}:{version}" + + class Package(BaseModel): model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) name: str version: VERSION_TYPE + @property + def coordinates(self): + return create_coordinates(package_name=self.name, version=self.version) + @property def normalized_name(self) -> NormalizedPackageStr: return normalize_package_name(self.name) diff --git a/exasol/toolbox/util/dependencies/track_changes.py b/exasol/toolbox/util/dependencies/track_changes.py index 2c907453b..249eb40bd 100644 --- a/exasol/toolbox/util/dependencies/track_changes.py +++ b/exasol/toolbox/util/dependencies/track_changes.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Optional - from packaging.version import Version from pydantic import ( BaseModel, @@ -11,6 +9,7 @@ from exasol.toolbox.util.dependencies.shared_models import ( NormalizedPackageStr, Package, + create_coordinates, ) @@ -24,7 +23,8 @@ class AddedDependency(DependencyChange): version: Version def __str__(self) -> str: - return f"* Added dependency `{self.name}:{self.version}`" + coordinates = create_coordinates(self.name, self.version) + return f"* Added dependency `{coordinates}`" @classmethod def from_package(cls, package: Package) -> AddedDependency: @@ -35,7 +35,8 @@ class RemovedDependency(DependencyChange): version: Version def __str__(self) -> str: - return f"* Removed dependency `{self.name}:{self.version}`" + coordinates = create_coordinates(self.name, self.version) + return f"* Removed dependency `{coordinates}`" @classmethod def from_package(cls, package: Package) -> RemovedDependency: @@ -47,10 +48,8 @@ class UpdatedDependency(DependencyChange): current_version: Version def __str__(self) -> str: - return ( - f"* Updated dependency `{self.name}:{self.previous_version}` " - f"to `{self.current_version}`" - ) + coordinates = create_coordinates(self.name, self.previous_version) + return f"* Updated dependency `{coordinates}` to `{self.current_version}`" @classmethod def from_package( diff --git a/test/conftest.py b/test/conftest.py index d4ca88699..e61c5bda4 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -66,6 +66,7 @@ def security_issue_entry(self) -> dict[str, str | list[str]]: "version": self.version, "refs": [self.vulnerability_id, self.cve_id], "description": self.description, + "coordinates": f"{self.package_name}:{self.version}", } @property diff --git a/test/unit/util/dependencies/shared_models_test.py b/test/unit/util/dependencies/shared_models_test.py index a768250d1..03e90f192 100644 --- a/test/unit/util/dependencies/shared_models_test.py +++ b/test/unit/util/dependencies/shared_models_test.py @@ -49,6 +49,11 @@ def test_normalized_name(name, expected): dep = Package(name=name, version="0.1.0") assert dep.normalized_name == expected + @staticmethod + def test_coordinates(): + dep = Package(name="numpy", version="0.1.0") + assert dep.coordinates == "numpy:0.1.0" + def test_poetry_files_from_latest_tag(): latest_tag = Git.get_latest_tag() From 5c12e59b65dd9371ef8a3fc2ef43885c9655d221 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Wed, 5 Nov 2025 15:00:01 +0100 Subject: [PATCH 07/14] Move VulnerabilitySource to audit.py for shared usage --- exasol/toolbox/tools/security.py | 29 ++--------------------- exasol/toolbox/util/dependencies/audit.py | 27 +++++++++++++++++++++ test/unit/security_test.py | 15 ------------ test/unit/util/dependencies/audit_test.py | 16 +++++++++++++ 4 files changed, 45 insertions(+), 42 deletions(-) diff --git a/exasol/toolbox/tools/security.py b/exasol/toolbox/tools/security.py index 7038a33b3..7a99df336 100644 --- a/exasol/toolbox/tools/security.py +++ b/exasol/toolbox/tools/security.py @@ -18,10 +18,11 @@ from functools import partial from inspect import cleandoc from pathlib import Path -from typing import Optional import typer +from exasol.toolbox.util.dependencies.audit import VulnerabilitySource + stdout = print stderr = partial(print, file=sys.stderr) @@ -104,32 +105,6 @@ def from_maven(report: str) -> Iterable[Issue]: ) -class VulnerabilitySource(str, Enum): - CVE = "CVE" - CWE = "CWE" - GHSA = "GHSA" - PYSEC = "PYSEC" - - @classmethod - def from_prefix(cls, name: str) -> VulnerabilitySource | None: - for el in cls: - if name.upper().startswith(el.value): - return el - return None - - def get_link(self, package: str, vuln_id: str) -> str: - if self == VulnerabilitySource.CWE: - cwe_id = vuln_id.upper().replace(f"{VulnerabilitySource.CWE.value}-", "") - return f"https://cwe.mitre.org/data/definitions/{cwe_id}.html" - - map_link = { - VulnerabilitySource.CVE: "https://nvd.nist.gov/vuln/detail/{vuln_id}", - VulnerabilitySource.GHSA: "https://github.com/advisories/{vuln_id}", - VulnerabilitySource.PYSEC: "https://github.com/pypa/advisory-database/blob/main/vulns/{package}/{vuln_id}.yaml", - } - return map_link[self].format(package=package, vuln_id=vuln_id) - - def identify_pypi_references( references: list[str], package_name: str ) -> tuple[list[str], list[str], list[str]]: diff --git a/exasol/toolbox/util/dependencies/audit.py b/exasol/toolbox/util/dependencies/audit.py index c33b53ebd..73c19ab02 100644 --- a/exasol/toolbox/util/dependencies/audit.py +++ b/exasol/toolbox/util/dependencies/audit.py @@ -4,6 +4,7 @@ import subprocess # nosec import tempfile from dataclasses import dataclass +from enum import Enum from pathlib import Path from re import search from typing import ( @@ -34,6 +35,32 @@ def __init__(self, subprocess_output: subprocess.CompletedProcess) -> None: self.stderr = subprocess_output.stderr +class VulnerabilitySource(str, Enum): + CVE = "CVE" + CWE = "CWE" + GHSA = "GHSA" + PYSEC = "PYSEC" + + @classmethod + def from_prefix(cls, name: str) -> VulnerabilitySource | None: + for el in cls: + if name.upper().startswith(el.value): + return el + return None + + def get_link(self, package: str, vuln_id: str) -> str: + if self == VulnerabilitySource.CWE: + cwe_id = vuln_id.upper().replace(f"{VulnerabilitySource.CWE.value}-", "") + return f"https://cwe.mitre.org/data/definitions/{cwe_id}.html" + + map_link = { + VulnerabilitySource.CVE: "https://nvd.nist.gov/vuln/detail/{vuln_id}", + VulnerabilitySource.GHSA: "https://github.com/advisories/{vuln_id}", + VulnerabilitySource.PYSEC: "https://github.com/pypa/advisory-database/blob/main/vulns/{package}/{vuln_id}.yaml", + } + return map_link[self].format(package=package, vuln_id=vuln_id) + + class Vulnerability(Package): id: str aliases: list[str] diff --git a/test/unit/security_test.py b/test/unit/security_test.py index 0da84cf91..b66b94a2d 100644 --- a/test/unit/security_test.py +++ b/test/unit/security_test.py @@ -330,21 +330,6 @@ def test_from_json(json_input, expected): assert list(actual) == [expected_issue] -@pytest.mark.parametrize( - "prefix,expected", - [ - pytest.param("DUMMY", None, id="without_a_matching_prefix_returns_none"), - pytest.param( - f"{security.VulnerabilitySource.CWE.value.lower()}-1234", - security.VulnerabilitySource.CWE, - id="with_matching_prefix_returns_vulnerability_source", - ), - ], -) -def test_from_prefix(prefix: str, expected): - assert security.VulnerabilitySource.from_prefix(prefix) == expected - - @pytest.mark.parametrize( "reference, expected", [ diff --git a/test/unit/util/dependencies/audit_test.py b/test/unit/util/dependencies/audit_test.py index b23cad480..c02fb740e 100644 --- a/test/unit/util/dependencies/audit_test.py +++ b/test/unit/util/dependencies/audit_test.py @@ -10,6 +10,7 @@ PipAuditException, Vulnerabilities, Vulnerability, + VulnerabilitySource, audit_poetry_files, get_vulnerabilities, get_vulnerabilities_from_latest_tag, @@ -138,6 +139,21 @@ def test_security_issue_dict(sample_vulnerability): assert result == [sample_vulnerability.security_issue_entry] +@pytest.mark.parametrize( + "prefix,expected", + [ + pytest.param("DUMMY", None, id="without_a_matching_prefix_returns_none"), + pytest.param( + f"{VulnerabilitySource.CWE.value.lower()}-1234", + VulnerabilitySource.CWE, + id="with_matching_prefix_returns_vulnerability_source", + ), + ], +) +def test_from_prefix(prefix: str, expected): + assert VulnerabilitySource.from_prefix(prefix) == expected + + class TestGetVulnerabilities: def test_with_mock(self, sample_vulnerability): with mock.patch( From 4e5e9bf6113373d9c6386946ac6f2e3430a409ef Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Wed, 5 Nov 2025 15:10:25 +0100 Subject: [PATCH 08/14] Move references_links to Vulnerability --- exasol/toolbox/tools/security.py | 18 +++++------ exasol/toolbox/util/dependencies/audit.py | 15 ++++++++- test/conftest.py | 7 +++-- test/unit/security_test.py | 24 +++++--------- test/unit/util/dependencies/audit_test.py | 38 +++++++++++++++++++++++ 5 files changed, 73 insertions(+), 29 deletions(-) diff --git a/exasol/toolbox/tools/security.py b/exasol/toolbox/tools/security.py index 7a99df336..2073a5d9a 100644 --- a/exasol/toolbox/tools/security.py +++ b/exasol/toolbox/tools/security.py @@ -105,19 +105,14 @@ def from_maven(report: str) -> Iterable[Issue]: ) -def identify_pypi_references( - references: list[str], package_name: str -) -> tuple[list[str], list[str], list[str]]: +def identify_pypi_references(references: list[str]) -> tuple[list[str], list[str]]: refs: dict = {k: [] for k in VulnerabilitySource} - links = [] for reference in references: if source := VulnerabilitySource.from_prefix(reference.upper()): refs[source].append(reference) - links.append(source.get_link(package=package_name, vuln_id=reference)) return ( refs[VulnerabilitySource.CVE], refs[VulnerabilitySource.CWE], - links, ) @@ -142,6 +137,11 @@ def from_pip_audit(report: str) -> Iterable[Issue]: "CVE-2025-27516" ], "description": "An oversight ..." + "coordinates": "jinja2:3.1.5", + "references": [ + "https://github.com/advisories/GHSA-cpwx-vrp4-4pq7", + "https://nvd.nist.gov/vuln/detail/CVE-2025-27516" + ] } ] @@ -153,8 +153,8 @@ def from_pip_audit(report: str) -> Iterable[Issue]: vulnerabilities = json.loads(report) for vulnerability in vulnerabilities: - cves, cwes, links = identify_pypi_references( - references=vulnerability["refs"], package_name=vulnerability["name"] + cves, cwes = identify_pypi_references( + references=vulnerability["refs"], ) if cves: yield Issue( @@ -162,7 +162,7 @@ def from_pip_audit(report: str) -> Iterable[Issue]: cwe="None" if not cwes else ", ".join(cwes), description=vulnerability["description"], coordinates=vulnerability["coordinates"], - references=tuple(links), + references=tuple(vulnerability["references"]), ) diff --git a/exasol/toolbox/util/dependencies/audit.py b/exasol/toolbox/util/dependencies/audit.py index 73c19ab02..4b0606b99 100644 --- a/exasol/toolbox/util/dependencies/audit.py +++ b/exasol/toolbox/util/dependencies/audit.py @@ -83,14 +83,27 @@ def from_audit_entry( description=vuln_entry["description"], ) + @property + def references(self) -> list[str]: + return [self.id] + self.aliases + + @property + def reference_links(self) -> tuple[str, ...]: + return tuple( + source.get_link(package=self.name, vuln_id=reference) + for reference in self.references + if (source := VulnerabilitySource.from_prefix(reference.upper())) + ) + @property def security_issue_entry(self) -> dict[str, str | list[str]]: return { "name": self.name, "version": str(self.version), - "refs": [self.id] + self.aliases, + "refs": self.references, "description": self.description, "coordinates": self.coordinates, + "references": self.reference_links, } diff --git a/test/conftest.py b/test/conftest.py index e61c5bda4..6e002575e 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,6 +1,5 @@ import json from inspect import cleandoc -from typing import Union import pytest @@ -60,13 +59,17 @@ def nox_dependencies_audit(self) -> str: return json.dumps([self.security_issue_entry], indent=2) + "\n" @property - def security_issue_entry(self) -> dict[str, str | list[str]]: + def security_issue_entry(self) -> dict[str, str | list[str] | tuple[str, ...]]: return { "name": self.package_name, "version": self.version, "refs": [self.vulnerability_id, self.cve_id], "description": self.description, "coordinates": f"{self.package_name}:{self.version}", + "references": ( + f"https://github.com/advisories/{self.vulnerability_id}", + f"https://nvd.nist.gov/vuln/detail/{self.cve_id}", + ), } @property diff --git a/test/unit/security_test.py b/test/unit/security_test.py index b66b94a2d..e0d65f52a 100644 --- a/test/unit/security_test.py +++ b/test/unit/security_test.py @@ -335,38 +335,28 @@ def test_from_json(json_input, expected): [ pytest.param( "CVE-2025-27516", - ( - ["CVE-2025-27516"], - [], - ["https://nvd.nist.gov/vuln/detail/CVE-2025-27516"], - ), + (["CVE-2025-27516"], []), id="CVE_identified_with_link", ), pytest.param( "CWE-611", - ([], ["CWE-611"], ["https://cwe.mitre.org/data/definitions/611.html"]), + ([], ["CWE-611"]), id="CWE_identified_with_link", ), pytest.param( "GHSA-cpwx-vrp4-4pq7", - ([], [], ["https://github.com/advisories/GHSA-cpwx-vrp4-4pq7"]), + ([], []), id="GHSA_link", ), pytest.param( "PYSEC-2025-9", - ( - [], - [], - [ - "https://github.com/pypa/advisory-database/blob/main/vulns/dummy/PYSEC-2025-9.yaml" - ], - ), + ([], []), id="PYSEC_link", ), ], ) def test_identify_pypi_references(reference: str, expected): - actual = security.identify_pypi_references([reference], package_name="dummy") + actual = security.identify_pypi_references([reference]) assert actual == expected @@ -378,7 +368,7 @@ def test_no_vulnerability_returns_empty_list(): @staticmethod def test_convert_vulnerability_to_issue(sample_vulnerability): - actual = set( + actual = next( security.from_pip_audit(sample_vulnerability.nox_dependencies_audit) ) - assert actual == {sample_vulnerability.security_issue} + assert actual == sample_vulnerability.security_issue diff --git a/test/unit/util/dependencies/audit_test.py b/test/unit/util/dependencies/audit_test.py index c02fb740e..293943f83 100644 --- a/test/unit/util/dependencies/audit_test.py +++ b/test/unit/util/dependencies/audit_test.py @@ -47,6 +47,44 @@ def test_security_issue_entry(sample_vulnerability): == sample_vulnerability.security_issue_entry ) + @staticmethod + @pytest.mark.parametrize( + "reference, expected", + [ + pytest.param( + "CVE-2025-27516", + "https://nvd.nist.gov/vuln/detail/CVE-2025-27516", + id="CVE", + ), + pytest.param( + "CWE-611", + "https://cwe.mitre.org/data/definitions/611.html", + id="CWE", + ), + pytest.param( + "GHSA-cpwx-vrp4-4pq7", + "https://github.com/advisories/GHSA-cpwx-vrp4-4pq7", + id="GHSA", + ), + pytest.param( + "PYSEC-2025-9", + "https://github.com/pypa/advisory-database/blob/main/vulns/jinja2/PYSEC-2025-9.yaml", + id="PYSEC", + ), + ], + ) + def test_reference_links(sample_vulnerability, reference: str, expected: list[str]): + result = Vulnerability( + name=sample_vulnerability.package_name, + version=sample_vulnerability.version, + id=reference, + aliases=[], + fix_versions=[sample_vulnerability.fix_version], + description=sample_vulnerability.description, + ) + + assert result.reference_links == (expected,) + class TestAuditPoetryFiles: @staticmethod From 27937f89f4ae60f45070ceb2e9474859ba4eb713 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 6 Nov 2025 10:08:35 +0100 Subject: [PATCH 09/14] Add vulnerability_id and subsection_for_changelog_summary with tests --- doc/changes/unreleased.md | 4 +++ exasol/toolbox/util/dependencies/audit.py | 28 ++++++++++++++- test/conftest.py | 6 ++-- test/unit/util/dependencies/audit_test.py | 43 +++++++++++++++++++++++ 4 files changed, 77 insertions(+), 4 deletions(-) diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index a3ce340a5..131f1b529 100644 --- a/doc/changes/unreleased.md +++ b/doc/changes/unreleased.md @@ -6,6 +6,10 @@ * #535: Added more information about Sonar's usage of ``exclusions`` * #596: Corrected and added more information regarding ``pyupgrade`` +## Features + +* #595: Created class `ResolvedVulnerabilities` to track resolved vulnerabilities between versions + ## Refactoring * #596: Added newline after header in versioned changelog diff --git a/exasol/toolbox/util/dependencies/audit.py b/exasol/toolbox/util/dependencies/audit.py index 4b0606b99..7e7f90d62 100644 --- a/exasol/toolbox/util/dependencies/audit.py +++ b/exasol/toolbox/util/dependencies/audit.py @@ -5,6 +5,7 @@ import tempfile from dataclasses import dataclass from enum import Enum +from inspect import cleandoc from pathlib import Path from re import search from typing import ( @@ -85,7 +86,7 @@ def from_audit_entry( @property def references(self) -> list[str]: - return [self.id] + self.aliases + return sorted([self.id] + self.aliases) @property def reference_links(self) -> tuple[str, ...]: @@ -106,6 +107,31 @@ def security_issue_entry(self) -> dict[str, str | list[str]]: "references": self.reference_links, } + @property + def vulnerability_id(self) -> str | None: + """ + Ensure a consistent way of identifying a vulnerability for string generation. + """ + for ref in self.references: + ref_upper = ref.upper() + if ref_upper.startswith(VulnerabilitySource.CVE.value): + return ref + if ref_upper.startswith(VulnerabilitySource.GHSA.value): + return ref + if ref_upper.startswith(VulnerabilitySource.PYSEC.value): + return ref + return self.references[0] + + @property + def subsection_for_changelog_summary(self) -> str: + """ + Create a subsection to be included in the Summary section of a versioned changelog. + """ + links_join = "\n* ".join(sorted(self.reference_links)) + references_subsection = f"\n#### References:\n\n* {links_join}\n\n " + subsection = f"### {self.vulnerability_id} in {self.coordinates}\n\n{self.description}\n{references_subsection}" + return cleandoc(subsection.strip()) + def audit_poetry_files(working_directory: Path) -> str: """ diff --git a/test/conftest.py b/test/conftest.py index 6e002575e..ab5fe8e5e 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -63,12 +63,12 @@ def security_issue_entry(self) -> dict[str, str | list[str] | tuple[str, ...]]: return { "name": self.package_name, "version": self.version, - "refs": [self.vulnerability_id, self.cve_id], + "refs": [self.cve_id, self.vulnerability_id], "description": self.description, "coordinates": f"{self.package_name}:{self.version}", "references": ( - f"https://github.com/advisories/{self.vulnerability_id}", f"https://nvd.nist.gov/vuln/detail/{self.cve_id}", + f"https://github.com/advisories/{self.vulnerability_id}", ), } @@ -80,8 +80,8 @@ def security_issue(self) -> Issue: description=self.description, coordinates=f"{self.package_name}:{self.version}", references=( - f"https://github.com/advisories/{self.vulnerability_id}", f"https://nvd.nist.gov/vuln/detail/{self.cve_id}", + f"https://github.com/advisories/{self.vulnerability_id}", ), ) diff --git a/test/unit/util/dependencies/audit_test.py b/test/unit/util/dependencies/audit_test.py index 293943f83..6294511b7 100644 --- a/test/unit/util/dependencies/audit_test.py +++ b/test/unit/util/dependencies/audit_test.py @@ -1,4 +1,5 @@ import json +from inspect import cleandoc from pathlib import Path from subprocess import CompletedProcess from unittest import mock @@ -85,6 +86,48 @@ def test_reference_links(sample_vulnerability, reference: str, expected: list[st assert result.reference_links == (expected,) + @pytest.mark.parametrize( + "aliases,expected", + ( + pytest.param(["A", "PYSEC", "CVE", "GHSA"], "CVE", id="CVE"), + pytest.param(["A", "PYSEC", "GHSA"], "GHSA", id="GHSA"), + pytest.param(["A", "PYSEC"], "PYSEC", id="PYSEC"), + pytest.param(["Z", "A"], "A", id="alphabetical_case"), + ), + ) + def test_vulnerability_id(self, sample_vulnerability, aliases: list[str], expected): + + result = Vulnerability( + name=sample_vulnerability.package_name, + version=sample_vulnerability.version, + id="DUMMY_IDENTIFIER", + aliases=aliases, + fix_versions=[sample_vulnerability.fix_version], + description=sample_vulnerability.description, + ) + + assert result.vulnerability_id == expected + + def test_subsection_for_changelog_summary(self, sample_vulnerability): + expected = cleandoc( + """ + ### CVE-2025-27516 in jinja2:3.1.5 + + An oversight in how the Jinja sandboxed environment interacts with the + `|attr` filter allows an attacker that controls the content of a template + to execute arbitrary Python code. + + #### References: + + * https://github.com/advisories/GHSA-cpwx-vrp4-4pq7 + * https://nvd.nist.gov/vuln/detail/CVE-2025-27516 + """ + ) + assert ( + sample_vulnerability.vulnerability.subsection_for_changelog_summary + == expected + ) + class TestAuditPoetryFiles: @staticmethod From 1509a0875155c92dbd07345e2d607124f02507b7 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Mon, 10 Nov 2025 13:30:03 +0100 Subject: [PATCH 10/14] Make docstring sentence clearer what the intent is by matching ID and aliases per review --- exasol/toolbox/util/dependencies/track_vulnerabilities.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/exasol/toolbox/util/dependencies/track_vulnerabilities.py b/exasol/toolbox/util/dependencies/track_vulnerabilities.py index 09e60580f..bd1353de8 100644 --- a/exasol/toolbox/util/dependencies/track_vulnerabilities.py +++ b/exasol/toolbox/util/dependencies/track_vulnerabilities.py @@ -19,9 +19,8 @@ def _is_resolved(self, previous_vuln: Vulnerability): A vulnerability is said to be resolved when it cannot be found in the `current_vulnerabilities`. In order to see if a vulnerability is still present, its id and aliases are compared to values in the - `current_vulnerabilities`. While one could hope that the IDs - are the same, it's possible between pip-audit versions that - there are differences. + `current_vulnerabilities`. It is hoped that if an ID were to change + that this would still be present in the aliases. """ previous_vuln_set = {previous_vuln.id, *previous_vuln.aliases} for current_vuln in self.current_vulnerabilities: From 0a3c9ae810518441bb2d0f90b4e609ab1b6695ed Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Mon, 10 Nov 2025 13:31:05 +0100 Subject: [PATCH 11/14] Add missing type hint --- exasol/toolbox/util/dependencies/shared_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exasol/toolbox/util/dependencies/shared_models.py b/exasol/toolbox/util/dependencies/shared_models.py index 9637d5177..90f3df4cd 100644 --- a/exasol/toolbox/util/dependencies/shared_models.py +++ b/exasol/toolbox/util/dependencies/shared_models.py @@ -44,7 +44,7 @@ class Package(BaseModel): version: VERSION_TYPE @property - def coordinates(self): + def coordinates(self) -> str: return create_coordinates(package_name=self.name, version=self.version) @property From c8a58c8146a63b2d5a6edafaee5dfa3f89d0442c Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Mon, 10 Nov 2025 13:42:47 +0100 Subject: [PATCH 12/14] Change structure of vulnerability so clearer delineation between package information and vulnerability --- exasol/toolbox/util/dependencies/audit.py | 23 +++++++++++-------- .../dependencies/track_vulnerabilities.py | 2 +- test/unit/util/dependencies/audit_test.py | 9 +++----- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/exasol/toolbox/util/dependencies/audit.py b/exasol/toolbox/util/dependencies/audit.py index 7e7f90d62..53fe0fc62 100644 --- a/exasol/toolbox/util/dependencies/audit.py +++ b/exasol/toolbox/util/dependencies/audit.py @@ -12,7 +12,10 @@ Any, ) -from pydantic import BaseModel +from pydantic import ( + BaseModel, + ConfigDict, +) from exasol.toolbox.util.dependencies.shared_models import ( Package, @@ -62,7 +65,10 @@ def get_link(self, package: str, vuln_id: str) -> str: return map_link[self].format(package=package, vuln_id=vuln_id) -class Vulnerability(Package): +class Vulnerability(BaseModel): + model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) + + package: Package id: str aliases: list[str] fix_versions: list[str] @@ -76,8 +82,7 @@ def from_audit_entry( Create a Vulnerability from a pip-audit vulnerability entry """ return cls( - name=package_name, - version=version, + package=Package(name=package_name, version=version), id=vuln_entry["id"], aliases=vuln_entry["aliases"], fix_versions=vuln_entry["fix_versions"], @@ -91,7 +96,7 @@ def references(self) -> list[str]: @property def reference_links(self) -> tuple[str, ...]: return tuple( - source.get_link(package=self.name, vuln_id=reference) + source.get_link(package=self.package.name, vuln_id=reference) for reference in self.references if (source := VulnerabilitySource.from_prefix(reference.upper())) ) @@ -99,11 +104,11 @@ def reference_links(self) -> tuple[str, ...]: @property def security_issue_entry(self) -> dict[str, str | list[str]]: return { - "name": self.name, - "version": str(self.version), + "name": self.package.name, + "version": str(self.package.version), "refs": self.references, "description": self.description, - "coordinates": self.coordinates, + "coordinates": self.package.coordinates, "references": self.reference_links, } @@ -129,7 +134,7 @@ def subsection_for_changelog_summary(self) -> str: """ links_join = "\n* ".join(sorted(self.reference_links)) references_subsection = f"\n#### References:\n\n* {links_join}\n\n " - subsection = f"### {self.vulnerability_id} in {self.coordinates}\n\n{self.description}\n{references_subsection}" + subsection = f"### {self.vulnerability_id} in {self.package.coordinates}\n\n{self.description}\n{references_subsection}" return cleandoc(subsection.strip()) diff --git a/exasol/toolbox/util/dependencies/track_vulnerabilities.py b/exasol/toolbox/util/dependencies/track_vulnerabilities.py index bd1353de8..d9d663797 100644 --- a/exasol/toolbox/util/dependencies/track_vulnerabilities.py +++ b/exasol/toolbox/util/dependencies/track_vulnerabilities.py @@ -24,7 +24,7 @@ def _is_resolved(self, previous_vuln: Vulnerability): """ previous_vuln_set = {previous_vuln.id, *previous_vuln.aliases} for current_vuln in self.current_vulnerabilities: - if previous_vuln.name == current_vuln.name: + if previous_vuln.package.name == current_vuln.package.name: current_vuln_id_set = {current_vuln.id, *current_vuln.aliases} if previous_vuln_set.intersection(current_vuln_id_set): return False diff --git a/test/unit/util/dependencies/audit_test.py b/test/unit/util/dependencies/audit_test.py index 6294511b7..ba91be6a1 100644 --- a/test/unit/util/dependencies/audit_test.py +++ b/test/unit/util/dependencies/audit_test.py @@ -33,8 +33,7 @@ class TestVulnerability: def test_from_audit_entry(sample_vulnerability): result = sample_vulnerability.vulnerability assert result == Vulnerability( - name=sample_vulnerability.package_name, - version=sample_vulnerability.version, + package=sample_vulnerability.vulnerability.package, id=sample_vulnerability.vulnerability_id, aliases=[sample_vulnerability.cve_id], fix_versions=[sample_vulnerability.fix_version], @@ -76,8 +75,7 @@ def test_security_issue_entry(sample_vulnerability): ) def test_reference_links(sample_vulnerability, reference: str, expected: list[str]): result = Vulnerability( - name=sample_vulnerability.package_name, - version=sample_vulnerability.version, + package=sample_vulnerability.vulnerability.package, id=reference, aliases=[], fix_versions=[sample_vulnerability.fix_version], @@ -98,8 +96,7 @@ def test_reference_links(sample_vulnerability, reference: str, expected: list[st def test_vulnerability_id(self, sample_vulnerability, aliases: list[str], expected): result = Vulnerability( - name=sample_vulnerability.package_name, - version=sample_vulnerability.version, + package=sample_vulnerability.vulnerability.package, id="DUMMY_IDENTIFIER", aliases=aliases, fix_versions=[sample_vulnerability.fix_version], From d8dc5d105fd94fe3605d7087fdf0e065d9fbdc7a Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Mon, 10 Nov 2025 13:43:55 +0100 Subject: [PATCH 13/14] Rename to create_package_coordinates --- exasol/toolbox/util/dependencies/shared_models.py | 4 ++-- exasol/toolbox/util/dependencies/track_changes.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/exasol/toolbox/util/dependencies/shared_models.py b/exasol/toolbox/util/dependencies/shared_models.py index 90f3df4cd..fa802c81c 100644 --- a/exasol/toolbox/util/dependencies/shared_models.py +++ b/exasol/toolbox/util/dependencies/shared_models.py @@ -30,7 +30,7 @@ def normalize_package_name(package_name: str) -> NormalizedPackageStr: return NormalizedPackageStr(package_name.lower().replace("_", "-")) -def create_coordinates(package_name: str, version: str | Version) -> str: +def create_package_coordinates(package_name: str, version: str | Version) -> str: """ Create a naming convention for combining a package name and its version """ @@ -45,7 +45,7 @@ class Package(BaseModel): @property def coordinates(self) -> str: - return create_coordinates(package_name=self.name, version=self.version) + return create_package_coordinates(package_name=self.name, version=self.version) @property def normalized_name(self) -> NormalizedPackageStr: diff --git a/exasol/toolbox/util/dependencies/track_changes.py b/exasol/toolbox/util/dependencies/track_changes.py index 249eb40bd..f010a415e 100644 --- a/exasol/toolbox/util/dependencies/track_changes.py +++ b/exasol/toolbox/util/dependencies/track_changes.py @@ -9,7 +9,7 @@ from exasol.toolbox.util.dependencies.shared_models import ( NormalizedPackageStr, Package, - create_coordinates, + create_package_coordinates, ) @@ -23,7 +23,7 @@ class AddedDependency(DependencyChange): version: Version def __str__(self) -> str: - coordinates = create_coordinates(self.name, self.version) + coordinates = create_package_coordinates(self.name, self.version) return f"* Added dependency `{coordinates}`" @classmethod @@ -35,7 +35,7 @@ class RemovedDependency(DependencyChange): version: Version def __str__(self) -> str: - coordinates = create_coordinates(self.name, self.version) + coordinates = create_package_coordinates(self.name, self.version) return f"* Removed dependency `{coordinates}`" @classmethod @@ -48,7 +48,7 @@ class UpdatedDependency(DependencyChange): current_version: Version def __str__(self) -> str: - coordinates = create_coordinates(self.name, self.previous_version) + coordinates = create_package_coordinates(self.name, self.previous_version) return f"* Updated dependency `{coordinates}` to `{self.current_version}`" @classmethod From 6b45589610275af6584d41100ed2001e349a2b12 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Mon, 10 Nov 2025 13:48:16 +0100 Subject: [PATCH 14/14] Fix type hints --- exasol/toolbox/util/dependencies/audit.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/exasol/toolbox/util/dependencies/audit.py b/exasol/toolbox/util/dependencies/audit.py index 53fe0fc62..a5724b6b5 100644 --- a/exasol/toolbox/util/dependencies/audit.py +++ b/exasol/toolbox/util/dependencies/audit.py @@ -102,7 +102,7 @@ def reference_links(self) -> tuple[str, ...]: ) @property - def security_issue_entry(self) -> dict[str, str | list[str]]: + def security_issue_entry(self) -> dict[str, str | list[str] | tuple[str, ...]]: return { "name": self.package.name, "version": str(self.package.version), @@ -215,7 +215,7 @@ def load_from_pip_audit(cls, working_directory: Path) -> Vulnerabilities: return Vulnerabilities(vulnerabilities=vulnerabilities) @property - def security_issue_dict(self) -> list[dict[str, str | list[str]]]: + def security_issue_dict(self) -> list[dict[str, str | list[str] | tuple[str, ...]]]: return [ vulnerability.security_issue_entry for vulnerability in self.vulnerabilities ]