diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index f735922f1c..719d444489 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -54,6 +54,7 @@ jobs: python-version: "3.9" - name: Run changelog update check + if: ${{ github.ref != 'refs/heads/main' }} run: poetry run nox -s changelog:updated build-matrix: @@ -136,25 +137,6 @@ jobs: path: .security.json include-hidden-files: true - Vulnerabilities: - name: Check Vulnerabilities (Python-${{ matrix.python-version }}) - needs: [ Version-Check, build-matrix ] - runs-on: ubuntu-24.04 - strategy: - matrix: ${{ fromJson(needs.build-matrix.outputs.matrix) }} - - steps: - - name: SCM Checkout - uses: actions/checkout@v4 - - - name: Setup Python & Poetry Environment - uses: ./.github/actions/python-environment - with: - python-version: ${{ matrix.python-version }} - - - name: Run Package vulnerabilities Check - run: poetry run nox -s dependency:audit - Format: name: Format Check runs-on: ubuntu-24.04 diff --git a/doc/_static/idioms/context_manager.py b/doc/_static/idioms/context_manager.py index b56d6dd825..e463c658ca 100644 --- a/doc/_static/idioms/context_manager.py +++ b/doc/_static/idioms/context_manager.py @@ -13,7 +13,7 @@ def chdir(path): def initialize(directory): with chdir(directory) as _working_dir: - with open('some-file.txt', 'w') as f: + with open("some-file.txt", "w") as f: f.write("Some content") @@ -23,7 +23,7 @@ def initialize(directory): def initialize(directory): with chdir(directory) as _working_dir: - with open('some-file.txt', 'w') as f: + with open("some-file.txt", "w") as f: f.write("Some content") @@ -35,6 +35,6 @@ def initialize(directory): old_dir = os.getcwd() os.chdir(directory) os.chdir(old_dir) - with open('some-file.txt', 'w') as f: + with open("some-file.txt", "w") as f: f.write("Some content") os.chdir(old_dir) diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index 3fa057e7a4..b5be190f38 100644 --- a/doc/changes/unreleased.md +++ b/doc/changes/unreleased.md @@ -5,6 +5,7 @@ * [#73](https://github.com/exasol/python-toolbox/issues/73): Added nox target for auditing work spaces in regard to known vulnerabilities * [#65](https://github.com/exasol/python-toolbox/issues/65): Added a Nox task for checking if the changelog got updated. * [#369](https://github.com/exasol/python-toolbox/issues/369): Removed option `-v` for `isort` +* [#372](https://github.com/exasol/python-toolbox/issues/372): Added conversion from pip-audit JSON to expected GitHub Issue format ## ⚒️ Refactorings * [#388](https://github.com/exasol/python-toolbox/issues/388): Switch GitHub workflows to use pinned OS version \ No newline at end of file diff --git a/doc/design.rst b/doc/design.rst index 0c7b55e6f1..35db9c6c5b 100644 --- a/doc/design.rst +++ b/doc/design.rst @@ -24,7 +24,7 @@ Overview This project mainly serves three main purposes: #. Provide library code, scripts and commands for common developer tasks within a python project. -#. Provide and maintain commonly required functionality for python project +#. Provide and maintain commonly required functionality for a python project * Common Projects Tasks - apply code formatters - lint project @@ -33,6 +33,7 @@ This project mainly serves three main purposes: - run integration tests - determine code coverage - build-, open-, clean- documentation + - creates GitHub Issues for vulnerabilities * CI (verify PR's and merges) * CI/CD (verify and publish releases) * Build & Publish Documentation (verify and publish documentation) @@ -45,16 +46,16 @@ Design Design Principles +++++++++++++++++ -* This project needs to be thought of as development dependency only! - - Library code should not imported/used in non development code of the projects +* This project needs to be thought of as a development dependency only! + - Library code should not imported/used in non-development code of the projects * Convention over configuration - Being able to assume conventions reduces the code base/paths significantly - First thought always should be: Can it be done easily by using/applying convention(s) - Use configuration if it's more practical or if it simplifies transitioning projects -* Provide extension points (hooks) where for project specific behaviour +* Provide extension points (hooks) for project specific behaviour - If it can't be a convention or configuration setting - If having something as a convention or configuration significantly complicates the implementation - - If you have a obvious use case within at least one project + - If you have an obvious use case within at least one project * KISS (Keep It Stupid Simple) - This project shall simplify the work of the developer, not add a burden on top - Try to automate as much as possible @@ -64,7 +65,7 @@ Design Principles .. note:: It is clear that not everything can and will be automated right from the beginning, - but there should be continues effort to improve the work of the developers. + but there should be continuous effort to improve the work of the developers. e.g.: @@ -114,18 +115,18 @@ Design Principles Design Decisions ++++++++++++++++ -* Whenever possible tools provided or required by the toolbox should get their configuration from the projects *pyproject.toml* file. -* Whenever a more dynamic configuration is needed it should be made part of the config object in the projects *noxconfig.py* file. -* The required standard tooling used within the toolbox will obey what have been agreed upon in the exasol `python-styleguide `_. -* As Task runner the toolbox will be using nox +* Whenever possible, tools provided or required by the toolbox should get their configuration from the projects *pyproject.toml* file. +* Whenever a more dynamic configuration is needed, it should be made part of the config object in the projects *noxconfig.py* file. +* The required standard tooling used within the toolbox will obey what has been agreed upon in the Exasol `python-styleguide `_. +* For a task runner, the toolbox will be using nox .. warning:: Known Issue(s) Nox tasks should not call (notify) other nox tasks. This can lead to unexpected behaviour due to the fact that the job/task queue will `execute a task only once `_. - Therefore all functionality which need to be reused or called multiple times within or by different nox tasks, - should be provided by python code (e.g. functions) which is receiving a nox session as argument - but isn't annotated as a nox session/task (`@nox.session `_). + Therefore, all functionality, which needs to be re-used, called multiple times calls, or is used by different nox tasks, + should be provided by python code (e.g. functions) which receives a nox session as an argument, but the code itself + shall not be annotated as a nox session/task (`@nox.session `_). .. note:: @@ -136,11 +137,11 @@ Design Decisions * It is already used by a couple of our projects, so the team is familiar with it * The author of the toolbox is very familiar with it - That said, no in depth evaluation of other tools haven been done. + That said, no in-depth evaluation of other tools has been done. -* Workflows (CI/CD & Co.) will be github actions based - - This is the standard tool within the exasol integration team +* Workflows (CI/CD & Co.) will be GitHub Actions-based + - This is the standard tool within the Exasol Integration Team * Workflows only shall provide an execution environment and orchestrate the execution itself Detailed Design @@ -150,36 +151,15 @@ Tasks ~~~~~ .. todo:: Add diagram configuration and tasks (noxfile.py + noxconfig.py + exasol.toolbox) -.. list-table:: - :header-rows: 1 - :widths: 30 70 +To view all the defined nox tasks & their definitions use: - * - Tasks - - Description - * - fix - - Runs all automated fixes on the code base - * - check - - Runs all available checks on the project - * - lint - - Runs the linter on the project - * - type-check - - Runs the type checker on the project - * - unit-tests - - Runs all unit tests - * - integration-tests - - Runs the all integration tests - * - coverage - - Runs all tests (unit + integration) and reports the code coverage - * - build-docs - - Builds the project documentation - * - open-docs - - Opens the built project documentation - * - clean-docs - - Removes the documentations build folder +.. code-block:: shell + + poetry run nox -l Workflows ~~~~~~~~~ -.. todo:: Add diagram of github workflows and interaction +.. todo:: Add diagram of GitHub workflows and interaction Available Workflows @@ -208,7 +188,91 @@ _________________ * - Action - Description * - python-environment - - Sets up an appropriate poetry based python environment + - Sets up an appropriate poetry-based python environment + * - security-issues + - Takes a JSON of known vulnerabilities affecting a repo & creates GitHub Issues + in said repo for any vulnerabilities, which do not yet have a GitHub Issue + +security-issues +^^^^^^^^^^^^^^^ +The `security-issues/action.yml` creates GitHub Issues for known vulnerabilities +for `maven `_ and `pip-audit `_. +The following steps are taken: + +1. Convert a JSON of known vulnerabilities into a common format (`class Issue`) +2. Filter out vulnerabilities which already have an existing GitHub Issue via CVE +3. Create new GitHub Issues +4. Return a JSON of the newly created GitHub Issues + +Input Variants +"""""""""""""" +An input variant would be passed in as a string-encoded JSON. + +`maven` (with `ossindex-audit `_) + +.. code-block:: json + + { + "vulnerable": { + "@:compile": { + "coordinates": "@", + "description": "", + "reference": "", + "vulnerabilities": [ + { + "id": "", + "displayName": "", + "title": "", + "description": "", + "cvssScore": 7.5, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", + "cwe": "", + "cve": "", + "reference": "", + "externalReferences": [""], + } + ], + }, + } + } + +`pip-audit` (via `nox -s dependency:audit`) + +.. code-block:: json + + { + "dependencies": [ + { + "name": "", + "version": "", + "vulns": + [ + { + "id": "", + "fix_versions": [""], + "aliases": [""], + "description": "" + } + ] + } + ] + } + +Known Issues +"""""""""""" +The `security-issues/action.yml` assumes that eventually every known vulnerability will +be associated with a singular CVE. + +* This can be problematic as vulnerabilities may be initially reported to different + services and not receive a CVE until a few days later or, in some cases, never. This + could mean that some vulnerabilities are initially missed or, in some cases, + never propagated by our action. +* Additionally, reporting tools like `pip-audit` must link a vulnerability with the + different vulnerability IDs from different reporting services. Typically, this is done + by selecting 1 of the vulnerability IDs as the unique identifier of the vulnerability. + This, as is the case for `pip-audit`, may not be the CVE, so it is possible if the + linked vulnerability IDs were to change (i.e. wrongly linked CVE) that we could end + up with multiple GitHub Issues for the same underlying vulnerability. Known Issues @@ -222,7 +286,7 @@ Passing files as individual arguments on the CLI **Description:** -As of today selection of python files for linting, formatting etc. is done by passing all relevant python files as individual argument(s) +As of today selection of Python files for linting, formatting etc. is done by passing all relevant python files as individual argument(s) to the tools used/invoked by the python toolbox. **Downsides:** @@ -312,7 +376,7 @@ While Nox isn't a perfect fit, it still meets most of our requirements for a tas **Rationale/History:** -Why Nox was choosen: +Why Nox was chosen: - No additional language(s) required: There was no need to introduce extra programming languages or binaries, simplifying the development process. - Python-based: Being Python-based, Nox can be extended and understood by Python developers. @@ -341,8 +405,8 @@ Poetry for Project Management +++++++++++++++++++++++++++++ While poetry was and is a good choice for Exasol project, dependency, build tool etc. "most recently" -`uv `_ has surfaced and made big advanced. Looking at uv it addresses additional itches with -our projects and therefore in the long run it may be a good idea to migrate our project setups to it. +`uv `_ has surfaced and made big advancements. Looking at uv it addresses additional itches with +our projects, and, therefore, in the long run, it may be a good idea to migrate our project setups to it. Use poetry for project, build and dependency management. @@ -351,7 +415,7 @@ Code Formatting **Description:** -Currently we use Black and Isort for code formatting, though running them on a larger code base as pre-commit hooks or such can take quite a bit of time. +Currently, we use Black and Isort for code formatting, though running them on a larger code base as pre-commit hooks or such can take quite a bit of time. **Downsides:** @@ -367,7 +431,7 @@ Currently we use Black and Isort for code formatting, though running them on a l **Ideas/Solutions:** -As `Ruff `_ is fairly stable and also tested and used by many Python projects +As `Ruff `_ is fairly stable and also tested and used by many Python projects, we should consider transitioning to it. Advantages: @@ -393,20 +457,20 @@ We are currently using Pylint instead of Ruff. **Rationale/History:** -- Well known +- Well-known - Pylint provides built-in project score/rating - Project score is good for improving legacy code bases which haven't been linted previously - Plugin support **Ideas/Possible Solutions:** -Replacing Pylint with Ruff for linting would provide significant performance improvement. Additionally, Ruff offers an LSP and IDE integrations and is widely used these days. Additionaly there would be an additional synergy if we adopt ruff for formatting the code base. +Replacing Pylint with Ruff for linting would provide significant performance improvement. Additionally, Ruff offers an LSP and IDE integrations and is widely used these days. Additionally, there would be an additional synergy if we adopt ruff for formatting the code base. Transitioning to Ruff requires us to adjust the migration and improvement strategies for our projects: - Currently, our codebase improvements are guided by scores. However, with Ruff, a new approach is necessary. For example, we could incrementally introduce specific linting rules, fix the related issues, and then enforce these rules. -- The project rating and scoring system will also need modification. One possiblity would be to run Ruff and Pylint in parallel, utilizing Pylint solely for rating and issue resolution while Ruff is incorporated for linting tasks. +- The project rating and scoring system will also need modification. One possibility would be to run Ruff and Pylint in parallel, utilizing Pylint solely for rating and issue resolution while Ruff is incorporated for linting tasks. Security Linter diff --git a/exasol/toolbox/nox/_dependencies.py b/exasol/toolbox/nox/_dependencies.py index 1933444fc9..7fb01b9160 100644 --- a/exasol/toolbox/nox/_dependencies.py +++ b/exasol/toolbox/nox/_dependencies.py @@ -1,8 +1,9 @@ from __future__ import annotations +import argparse +import json import subprocess import tempfile -from collections import defaultdict from dataclasses import dataclass from inspect import cleandoc from json import loads @@ -154,7 +155,7 @@ def _packages_to_markdown( dependencies: dict[str, list], packages: list[Package] ) -> str: def heading(): - text = "# Dependecies\n" + text = "# Dependencies\n" return text def dependency(group: str, group_packages: list, packages: list[Package]) -> str: @@ -212,8 +213,70 @@ def _normalize_package_name(name: str) -> str: return template.format(heading=heading(), rows=rows) -def _audit(session: Session) -> None: - session.run("poetry", "run", "pip-audit") +class Audit: + @staticmethod + def _filter_json_for_vulnerabilities(audit_json_bytes: bytes) -> dict: + """ + Filters JSON from pip-audit for only packages with vulnerabilities + + Examples: + >>> audit_json_dict = {"dependencies": [ + ... {"name": "alabaster", "version": "0.7.16", "vulns": []}, + ... {"name": "cryptography", "version": "43.0.3", "vulns": + ... [{"id": "GHSA-79v4-65xg-pq4g", "fix_versions": ["44.0.1"], + ... "aliases": ["CVE-2024-12797"], + ... "description": "pyca/cryptography\'s wheels..."}]}]} + >>> audit_json = json.dumps(audit_json_dict).encode() + >>> Audit._filter_json_for_vulnerabilities(audit_json) + {"dependencies": [{"name": "cryptography", "version": "43.0.3", "vulns": + [{"id": "GHSA-79v4-65xg-pq4g", "fix_versions": ["44.0.1"], "aliases": + ["CVE-2024-12797"], "description": "pyca/cryptography\'s wheels..."}]}]} + """ + audit_dict = json.loads(audit_json_bytes.decode("utf-8")) + return { + "dependencies": [ + { + "name": entry["name"], + "version": entry["version"], + "vulns": entry["vulns"], + } + for entry in audit_dict["dependencies"] + if entry["vulns"] + ] + } + + @staticmethod + def _parse_args(session) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Audits dependencies for security vulnerabilities", + usage="nox -s dependency:audit -- -- [options]", + ) + parser.add_argument( + "-o", + "--output", + type=Path, + default=None, + help="Output results to the given file", + ) + return parser.parse_args(args=session.posargs) + + def run(self, session: Session) -> None: + args = self._parse_args(session) + + command = ["poetry", "run", "pip-audit", "-f", "json"] + output = subprocess.run(command, capture_output=True) + + audit_json = self._filter_json_for_vulnerabilities(output.stdout) + if args.output: + with open(args.output, "w") as file: + json.dump(audit_json, file) + else: + print(json.dumps(audit_json, indent=2)) + + if output.returncode != 0: + session.warn( + f"Command {' '.join(command)} failed with exit code {output.returncode}", + ) @nox.session(name="dependency:licenses", python=False) @@ -228,4 +291,4 @@ def dependency_licenses(session: Session) -> None: @nox.session(name="dependency:audit", python=False) def audit(session: Session) -> None: """Check for known vulnerabilities""" - _audit(session) + Audit().run(session=session) diff --git a/exasol/toolbox/nox/_documentation.py b/exasol/toolbox/nox/_documentation.py index 257e89cf15..6c3ee3a5ac 100644 --- a/exasol/toolbox/nox/_documentation.py +++ b/exasol/toolbox/nox/_documentation.py @@ -4,7 +4,6 @@ import subprocess import sys import webbrowser -from pathlib import Path import nox from nox import Session diff --git a/exasol/toolbox/templates/github/workflows/checks.yml b/exasol/toolbox/templates/github/workflows/checks.yml index a475954ba2..c9fbe11298 100644 --- a/exasol/toolbox/templates/github/workflows/checks.yml +++ b/exasol/toolbox/templates/github/workflows/checks.yml @@ -139,25 +139,6 @@ jobs: path: .security.json include-hidden-files: true - Vulnerabilities: - name: Check Vulnerabilities (Python-${{ matrix.python-version }}) - needs: [ Version-Check, build-matrix ] - runs-on: ubuntu-24.04 - strategy: - matrix: ${{ fromJson(needs.build-matrix.outputs.matrix) }} - - steps: - - name: SCM Checkout - uses: actions/checkout@v4 - - - name: Setup Python & Poetry Environment - uses: ./.github/actions/python-environment - with: - python-version: ${{ matrix.python-version }} - - - name: Run Package vulnerabilities Check - run: poetry run nox -s dependency:audit - Format: name: Format Check runs-on: ubuntu-24.04 diff --git a/exasol/toolbox/tools/security.py b/exasol/toolbox/tools/security.py index af23128ee3..4eb96c908c 100644 --- a/exasol/toolbox/tools/security.py +++ b/exasol/toolbox/tools/security.py @@ -1,5 +1,7 @@ """This module contains security related CLI tools and code""" +from __future__ import annotations + import json import re import subprocess @@ -16,7 +18,6 @@ from functools import partial from inspect import cleandoc from pathlib import Path -from typing import Tuple import typer @@ -35,7 +36,7 @@ class Issue: references: tuple -def _issues(input) -> Generator[Issue, None, None]: +def _issues(input) -> Generator[Issue]: lines = (l for l in input if l.strip() != "") for line in lines: obj = json.loads(line) @@ -48,7 +49,7 @@ def _issues_as_json_str(issues): yield json.dumps(issue) -def gh_security_issues() -> Generator[tuple[str, str], None, None]: +def gh_security_issues() -> Generator[tuple[str, str]]: """ Yields issue-id, cve-id pairs for all (closed, open) issues associated with CVEs @@ -102,6 +103,87 @@ 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]]: + 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, + ) + + +def from_pip_audit(report: str) -> Iterable[Issue]: + """ + Transforms the JSON output from `nox -s dependency:audit` into an iterable of + `security.Issue` objects. + + This does not gracefully handle scenarios where: + - a CVE is not initially associated with the vulnerability; however, the assumption + is that such vulnerabilities will later be associated with a CVE. + - the same vulnerability ID (CVE, PYSEC, GHSA, etc.) is present across + multiple coordinates. + + Input: + '{"dependencies": [{"name": "", "version": "", + "vulns": [{"id": "", "fix_versions": [""], + "aliases": [""], "description": ""}]}]}' + + Args: + report: + the JSON output of `nox -s dependency:audit` provided as a str + """ + report_dict = json.loads(report) + dependencies = report_dict.get("dependencies", []) + for dependency in dependencies: + package = dependency["name"] + for v in dependency["vulns"]: + refs = [v["id"]] + v["aliases"] + cves, cwes, links = identify_pypi_references( + references=refs, package_name=package + ) + if cves: + yield Issue( + cve=sorted(cves)[0], + cwe="None" if not cwes else ", ".join(cwes), + description=v["description"], + coordinates=f"{package}:{dependency['version']}", + references=tuple(links), + ) + + @dataclass(frozen=True) class SecurityIssue: file_name: str @@ -187,7 +269,7 @@ def as_markdown_listing(elements: Iterable[str]): ) -def create_security_issue(issue: Issue, project="") -> tuple[str, str]: +def create_security_issue(issue: Issue, project: str | None = None) -> tuple[str, str]: # fmt: off command = [ "gh", "issue", "create", @@ -220,6 +302,7 @@ def create_security_issue(issue: Issue, project="") -> tuple[str, str]: class Format(str, Enum): Maven = "maven" + PipAudit = "pip-audit" # pylint: disable=redefined-builtin @@ -243,7 +326,13 @@ def _maven(infile): stdout(issue) raise typer.Exit(code=0) - actions = {Format.Maven: _maven} + def _pip_audit(infile): + issues = from_pip_audit(infile.read()) + for issue in _issues_as_json_str(issues): + stdout(issue) + raise typer.Exit(code=0) + + actions = {Format.Maven: _maven, Format.PipAudit: _pip_audit} action = actions[format] action(input_file) diff --git a/metrics-schema/metrics_schema.py b/metrics-schema/metrics_schema.py index 3f2958c8bb..b7f9e2ccca 100644 --- a/metrics-schema/metrics_schema.py +++ b/metrics-schema/metrics_schema.py @@ -1,8 +1,7 @@ -import sys -from pathlib import Path import json +import sys from datetime import datetime -from inspect import cleandoc +from pathlib import Path from pydantic import ( BaseModel, @@ -10,7 +9,7 @@ ) _TOOLBOX_PATH = Path(__file__).parent / ".." -sys.path.append(f'{_TOOLBOX_PATH}') +sys.path.append(f"{_TOOLBOX_PATH}") from exasol.toolbox.metrics import Rating @@ -18,11 +17,7 @@ class Metrics(BaseModel): """This schema defines the structure and values for reporting Q/A metrics for projects.""" - project: str = Field( - description=( - "Project Name Corresponding to the metrics." - ) - ) + project: str = Field(description=("Project Name Corresponding to the metrics.")) commit: str = Field( description=( "Commit-Hash pointing to the state of the codebase used for generating the metrics." @@ -33,7 +28,8 @@ class Metrics(BaseModel): ) coverage: float = Field( description="Represents the percentage of the codebase that is covered by automated tests.", - ge=0, le=100 + ge=0, + le=100, ) maintainability: Rating = Field( description="Rating of how easily the codebase can be understood, adapted, and extended.", diff --git a/project-template/{{cookiecutter.repo_name}}/noxconfig.py b/project-template/{{cookiecutter.repo_name}}/noxconfig.py index 13f5069628..3f9329323d 100644 --- a/project-template/{{cookiecutter.repo_name}}/noxconfig.py +++ b/project-template/{{cookiecutter.repo_name}}/noxconfig.py @@ -2,9 +2,7 @@ from dataclasses import dataclass from pathlib import Path -from typing import ( - Iterable, -) +from typing import Iterable @dataclass(frozen=True) @@ -12,7 +10,10 @@ class Config: root: Path = Path(__file__).parent doc: Path = Path(__file__).parent / "doc" version_file: Path = ( - Path(__file__).parent / "exasol" / "{{cookiecutter.package_name}}" / "version.py" + Path(__file__).parent + / "exasol" + / "{{cookiecutter.package_name}}" + / "version.py" ) path_filters: Iterable[str] = ( "dist", diff --git a/project-template/{{cookiecutter.repo_name}}/test/unit/unit_smoke_test.py b/project-template/{{cookiecutter.repo_name}}/test/unit/unit_smoke_test.py index b9c1f5facf..7499bf1a63 100644 --- a/project-template/{{cookiecutter.repo_name}}/test/unit/unit_smoke_test.py +++ b/project-template/{{cookiecutter.repo_name}}/test/unit/unit_smoke_test.py @@ -1,4 +1,5 @@ import {{ cookiecutter.import_package }} + def test_unit_smoke_test(): assert True diff --git a/test/unit/conftest.py b/test/unit/conftest.py new file mode 100644 index 0000000000..1703e52a1c --- /dev/null +++ b/test/unit/conftest.py @@ -0,0 +1,90 @@ +from inspect import cleandoc + +import pytest + +from exasol.toolbox.tools import security + + +@pytest.fixture(scope="session") +def pip_audit_jinja2_issue(): + return security.Issue( + cve="CVE-2025-27516", + cwe="None", + description=cleandoc( + """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. To exploit the vulnerability, an + attacker needs to control the content of a template. Whether that is the + case depends on the type of application using Jinja. This vulnerability + impacts users of applications which execute untrusted templates. Jinja's + sandbox does catch calls to `str.format` and ensures they don't escape the + sandbox. However, it's possible to use the `|attr` filter to get a + reference to a string's plain format method, bypassing the sandbox. After + the fix, the `|attr` filter no longer bypasses the environment's attribute + lookup.""" + ), + coordinates="jinja2:3.1.5", + references=( + "https://github.com/advisories/GHSA-cpwx-vrp4-4pq7", + "https://nvd.nist.gov/vuln/detail/CVE-2025-27516", + ), + ) + + +@pytest.fixture(scope="session") +def pip_audit_cryptography_issue(): + return security.Issue( + cve="CVE-2024-12797", + cwe="None", + description=cleandoc( + """pyca / cryptography's wheels include a statically linked copy of + OpenSSL. The versions of OpenSSL included in cryptography 42.0.0 - 44.0.0 + are vulnerable to a security issue. More details about the vulnerability + itself can be found in https://openssl-library.org/news/secadv/20250211.txt. + If you are building cryptography source(\"sdist\") then you are responsible + for upgrading your copy of OpenSSL. Only users installing from wheels built + by the cryptography project(i.e., those distributed on PyPI) need to update + their cryptography versions.""" + ), + coordinates="cryptography:43.0.3", + references=( + "https://github.com/advisories/GHSA-79v4-65xg-pq4g", + "https://nvd.nist.gov/vuln/detail/CVE-2024-12797", + ), + ) + + +@pytest.fixture(scope="session") +def pip_audit_report(pip_audit_jinja2_issue, pip_audit_cryptography_issue): + jinja2_name, jinja2_version = pip_audit_jinja2_issue.coordinates.split(":") + cryptography_name, cryptography_version = ( + pip_audit_cryptography_issue.coordinates.split(":") + ) + return { + "dependencies": [ + { + "name": jinja2_name, + "version": jinja2_version, + "vulns": [ + { + "id": "GHSA-cpwx-vrp4-4pq7", + "fix_versions": ["3.1.6"], + "aliases": [pip_audit_jinja2_issue.cve], + "description": pip_audit_jinja2_issue.description, + } + ], + }, + { + "name": cryptography_name, + "version": cryptography_version, + "vulns": [ + { + "id": "GHSA-79v4-65xg-pq4g", + "fix_versions": ["44.0.1"], + "aliases": [pip_audit_cryptography_issue.cve], + "description": pip_audit_cryptography_issue.description, + } + ], + }, + ] + } diff --git a/test/unit/dependencies_check_test.py b/test/unit/dependencies_check_test.py index 4c4218ba88..617a4f1a64 100644 --- a/test/unit/dependencies_check_test.py +++ b/test/unit/dependencies_check_test.py @@ -122,7 +122,7 @@ ], ) def test_dependency_check_parse(toml, expected): - dependencies = dependencies = Dependencies.parse(toml) + dependencies = Dependencies.parse(toml) assert dependencies.illegal == expected diff --git a/test/unit/dependencies_test.py b/test/unit/dependencies_test.py index 49558760bc..49e83df633 100644 --- a/test/unit/dependencies_test.py +++ b/test/unit/dependencies_test.py @@ -1,6 +1,9 @@ +import json + import pytest from exasol.toolbox.nox._dependencies import ( + Audit, Package, _dependencies, _normalize, @@ -138,7 +141,7 @@ def test_packages_from_json(json, expected): license_link="", ), ], - """# Dependecies + """# Dependencies ## Project Dependencies |Package|version|Licence| |---|---|---| @@ -157,3 +160,24 @@ def test_packages_from_json(json, expected): def test_packages_to_markdown(dependencies, packages, expected): actual = _packages_to_markdown(dependencies, packages) assert actual == expected + + +class TestFilterJsonForVulnerabilities: + + @staticmethod + def test_no_vulnerability_returns_empty_list(): + audit_dict = { + "dependencies": [{"name": "alabaster", "version": "0.7.16", "vulns": []}] + } + audit_json = json.dumps(audit_dict).encode("utf-8") + expected = {"dependencies": []} + + actual = Audit._filter_json_for_vulnerabilities(audit_json) + assert actual == expected + + @staticmethod + def test_vulnerabilities_returned_in_list(pip_audit_report): + audit_json = json.dumps(pip_audit_report).encode("utf-8") + + actual = Audit._filter_json_for_vulnerabilities(audit_json) + assert actual == pip_audit_report diff --git a/test/unit/security_test.py b/test/unit/security_test.py index 42b26d1a4f..1cdcf497a5 100644 --- a/test/unit/security_test.py +++ b/test/unit/security_test.py @@ -462,3 +462,75 @@ def test_from_json(json_file, expected): references=expected["references"], ) 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"], + ), + id="CVE_identified_with_link", + ), + pytest.param( + "CWE-611", + ([], ["CWE-611"], ["https://cwe.mitre.org/data/definitions/611.html"]), + 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") + assert actual == expected + + +class TestFromPipAudit: + @staticmethod + def test_no_vulnerability_returns_empty_list(): + actual = set(security.from_pip_audit("{}")) + assert actual == set() + + @staticmethod + def test_convert_vulnerability_to_issue( + pip_audit_report, pip_audit_jinja2_issue, pip_audit_cryptography_issue + ): + audit_json = json.dumps(pip_audit_report) + expected = {pip_audit_jinja2_issue, pip_audit_cryptography_issue} + + actual = set(security.from_pip_audit(audit_json)) + assert actual == expected