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/nox/_lint.py b/exasol/toolbox/nox/_lint.py index 890839a10..b8d74f201 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.shared_models 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..cf476a512 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.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 ( @@ -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/tools/security.py b/exasol/toolbox/tools/security.py index 18e45dad7..2073a5d9a 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,45 +105,14 @@ 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]]: +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, ) @@ -167,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" + ] } ] @@ -178,16 +153,16 @@ 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( cve=sorted(cves)[0], cwe="None" if not cwes else ", ".join(cwes), description=vulnerability["description"], - coordinates=f"{vulnerability['name']}:{vulnerability['version']}", - references=tuple(links), + coordinates=vulnerability["coordinates"], + references=tuple(vulnerability["references"]), ) diff --git a/exasol/toolbox/util/dependencies/audit.py b/exasol/toolbox/util/dependencies/audit.py index 6cdd75aa6..a5724b6b5 100644 --- a/exasol/toolbox/util/dependencies/audit.py +++ b/exasol/toolbox/util/dependencies/audit.py @@ -4,16 +4,23 @@ import subprocess # nosec 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 ( Any, - Union, ) -from pydantic import BaseModel +from pydantic import ( + BaseModel, + ConfigDict, +) -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?$" @@ -32,7 +39,36 @@ def __init__(self, subprocess_output: subprocess.CompletedProcess) -> None: self.stderr = subprocess_output.stderr -class Vulnerability(Package): +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(BaseModel): + model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) + + package: Package id: str aliases: list[str] fix_versions: list[str] @@ -46,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"], @@ -55,14 +90,53 @@ def from_audit_entry( ) @property - def security_issue_entry(self) -> dict[str, str | list[str]]: + def references(self) -> list[str]: + return sorted([self.id] + self.aliases) + + @property + def reference_links(self) -> tuple[str, ...]: + return tuple( + source.get_link(package=self.package.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] | tuple[str, ...]]: return { - "name": self.name, - "version": str(self.version), - "refs": [self.id] + self.aliases, + "name": self.package.name, + "version": str(self.package.version), + "refs": self.references, "description": self.description, + "coordinates": self.package.coordinates, + "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.package.coordinates}\n\n{self.description}\n{references_subsection}" + return cleandoc(subsection.strip()) + def audit_poetry_files(working_directory: Path) -> str: """ @@ -141,7 +215,18 @@ 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 ] + + +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 get_vulnerabilities(working_directory=tmp_dir) diff --git a/exasol/toolbox/util/dependencies/poetry_dependencies.py b/exasol/toolbox/util/dependencies/poetry_dependencies.py index b801e4b39..222159778 100644 --- a/exasol/toolbox/util/dependencies/poetry_dependencies.py +++ b/exasol/toolbox/util/dependencies/poetry_dependencies.py @@ -1,10 +1,8 @@ from __future__ import annotations import subprocess -import tempfile from collections import OrderedDict from pathlib import Path -from typing import Optional import tomlkit from pydantic import ( @@ -16,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): @@ -28,7 +26,6 @@ class PoetryGroup(BaseModel): toml_section: str | None -PYPROJECT_TOML = "pyproject.toml" TRANSITIVE_GROUP = PoetryGroup(name="transitive", toml_section=None) @@ -39,7 +36,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}") @@ -165,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 ("poetry.lock", PYPROJECT_TOML): - 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..fa802c81c 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))] @@ -21,12 +30,45 @@ def normalize_package_name(package_name: str) -> NormalizedPackageStr: return NormalizedPackageStr(package_name.lower().replace("_", "-")) +def create_package_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) -> str: + return create_package_coordinates(package_name=self.name, version=self.version) + @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 diff --git a/exasol/toolbox/util/dependencies/track_changes.py b/exasol/toolbox/util/dependencies/track_changes.py index 2c907453b..f010a415e 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_package_coordinates, ) @@ -24,7 +23,8 @@ class AddedDependency(DependencyChange): version: Version def __str__(self) -> str: - return f"* Added dependency `{self.name}:{self.version}`" + coordinates = create_package_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_package_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_package_coordinates(self.name, self.previous_version) + return f"* Updated dependency `{coordinates}` to `{self.current_version}`" @classmethod def from_package( diff --git a/exasol/toolbox/util/dependencies/track_vulnerabilities.py b/exasol/toolbox/util/dependencies/track_vulnerabilities.py new file mode 100644 index 000000000..d9d663797 --- /dev/null +++ b/exasol/toolbox/util/dependencies/track_vulnerabilities.py @@ -0,0 +1,42 @@ +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`. 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: + 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 + 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/conftest.py b/test/conftest.py index d4ca88699..ab5fe8e5e 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,12 +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], + "refs": [self.cve_id, self.vulnerability_id], "description": self.description, + "coordinates": f"{self.package_name}:{self.version}", + "references": ( + f"https://nvd.nist.gov/vuln/detail/{self.cve_id}", + f"https://github.com/advisories/{self.vulnerability_id}", + ), } @property @@ -76,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/security_test.py b/test/unit/security_test.py index 0da84cf91..e0d65f52a 100644 --- a/test/unit/security_test.py +++ b/test/unit/security_test.py @@ -330,58 +330,33 @@ 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", [ 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 @@ -393,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 6b50fe98a..ba91be6a1 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 @@ -10,8 +11,12 @@ PipAuditException, Vulnerabilities, Vulnerability, + VulnerabilitySource, audit_poetry_files, + get_vulnerabilities, + get_vulnerabilities_from_latest_tag, ) +from noxconfig import PROJECT_CONFIG @pytest.fixture @@ -28,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], @@ -43,6 +47,84 @@ 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( + package=sample_vulnerability.vulnerability.package, + id=reference, + aliases=[], + fix_versions=[sample_vulnerability.fix_version], + description=sample_vulnerability.description, + ) + + 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( + package=sample_vulnerability.vulnerability.package, + 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 @@ -133,3 +215,44 @@ def test_security_issue_dict(sample_vulnerability): ) result = vulnerabilities.security_issue_dict 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( + "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 diff --git a/test/unit/util/dependencies/shared_models_test.py b/test/unit/util/dependencies/shared_models_test.py index e16b78fc9..03e90f192 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,18 @@ class TestPackage: 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() + 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 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]