From 3f9290ccf73da222ad7ef636875c873f2e8944c5 Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Thu, 3 Apr 2025 13:42:53 +0200 Subject: [PATCH 01/34] Add: Support for the openvasd HTTP API A new subpackage for sending requests to HTTP APIs has been added which also includes the first version of the openvasd API. --- gvm/http/__init__.py | 3 + gvm/http/core/__init__.py | 3 + gvm/http/core/api.py | 28 ++ gvm/http/core/connector.py | 154 ++++++++++ gvm/http/core/headers.py | 53 ++++ gvm/http/core/response.py | 45 +++ gvm/http/openvasd/__init__.py | 3 + gvm/http/openvasd/openvasd1.py | 333 ++++++++++++++++++++++ poetry.lock | 12 +- pyproject.toml | 1 + tests/http/__init__.py | 3 + tests/http/core/__init__.py | 3 + tests/http/core/test_api.py | 23 ++ tests/http/core/test_connector.py | 394 ++++++++++++++++++++++++++ tests/http/core/test_headers.py | 40 +++ tests/http/core/test_response.py | 60 ++++ tests/http/openvasd/__init__.py | 3 + tests/http/openvasd/test_openvasd1.py | 344 ++++++++++++++++++++++ 18 files changed, 1499 insertions(+), 6 deletions(-) create mode 100644 gvm/http/__init__.py create mode 100644 gvm/http/core/__init__.py create mode 100644 gvm/http/core/api.py create mode 100644 gvm/http/core/connector.py create mode 100644 gvm/http/core/headers.py create mode 100644 gvm/http/core/response.py create mode 100644 gvm/http/openvasd/__init__.py create mode 100644 gvm/http/openvasd/openvasd1.py create mode 100644 tests/http/__init__.py create mode 100644 tests/http/core/__init__.py create mode 100644 tests/http/core/test_api.py create mode 100644 tests/http/core/test_connector.py create mode 100644 tests/http/core/test_headers.py create mode 100644 tests/http/core/test_response.py create mode 100644 tests/http/openvasd/__init__.py create mode 100644 tests/http/openvasd/test_openvasd1.py diff --git a/gvm/http/__init__.py b/gvm/http/__init__.py new file mode 100644 index 00000000..9c0a68e7 --- /dev/null +++ b/gvm/http/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/gvm/http/core/__init__.py b/gvm/http/core/__init__.py new file mode 100644 index 00000000..9c0a68e7 --- /dev/null +++ b/gvm/http/core/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/gvm/http/core/api.py b/gvm/http/core/api.py new file mode 100644 index 00000000..4ed4a15f --- /dev/null +++ b/gvm/http/core/api.py @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import Optional + +from gvm.http.core.connector import HttpApiConnector + + +class GvmHttpApi: + """ + Base class for HTTP-based GVM APIs. + """ + + def __init__(self, connector: HttpApiConnector, *, api_key: Optional[str] = None): + """ + Create a new generic GVM HTTP API instance. + + Args: + connector: The connector handling the HTTP(S) connection + api_key: Optional API key for authentication + """ + + "The connector handling the HTTP(S) connection" + self._connector: HttpApiConnector = connector + + "Optional API key for authentication" + self._api_key: Optional[str] = api_key diff --git a/gvm/http/core/connector.py b/gvm/http/core/connector.py new file mode 100644 index 00000000..20bf2270 --- /dev/null +++ b/gvm/http/core/connector.py @@ -0,0 +1,154 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import urllib.parse +from typing import Optional, Tuple, Dict, Any + +from requests import Session + +from gvm.http.core.response import HttpResponse + + +def url_join(base: str, rel_path: str) -> str: + """ + Combines a base URL and a relative path into one URL. + + Unlike `urrlib.parse.urljoin` the base path will always be the parent of the relative path as if it + ends with "/". + """ + if base.endswith("/"): + return urllib.parse.urljoin(base, rel_path) + else: + return urllib.parse.urljoin(base + "/", rel_path) + + +class HttpApiConnector: + """ + Class for connecting to HTTP based API servers, sending requests and receiving the responses. + """ + + @classmethod + def _new_session(cls): + """ + Creates a new session + """ + return Session() + + def __init__( + self, + base_url: str, + *, + server_ca_path: Optional[str] = None, + client_cert_paths: Optional[str | Tuple[str]] = None, + ): + """ + Create a new HTTP API Connector. + + Args: + base_url: The base server URL to which request-specific paths will be appended for the requests + server_ca_path: Optional path to a CA certificate for verifying the server. + If none is given, server verification is disabled. + client_cert_paths: Optional path to a client private key and certificate for authentication. + Can be a combined key and certificate file or a tuple containing separate files. + The key must not be encrypted. + """ + + self.base_url = base_url + "The base server URL to which request-specific paths will be appended for the requests" + + self._session = self._new_session() + "Internal session handling the HTTP requests" + if server_ca_path: + self._session.verify = server_ca_path + if client_cert_paths: + self._session.cert = client_cert_paths + + def update_headers(self, new_headers: Dict[str, str]) -> None: + """ + Updates the headers sent with each request, e.g. for passing an API key + + Args: + new_headers: Dict containing the new headers + """ + self._session.headers.update(new_headers) + + def delete( + self, + rel_path: str, + *, + raise_for_status: bool = True, + params: Optional[Dict[str,str]] = None, + headers: Optional[Dict[str,str]] = None, + ) -> HttpResponse: + """ + Sends a ``DELETE`` request and returns the response. + + Args: + rel_path: The relative path for the request + raise_for_status: Whether to raise an error if response has a non-success HTTP status code + params: Optional dict of URL-encoded parameters + headers: Optional additional headers added to the request + + Return: + The HTTP response. + """ + url = url_join(self.base_url, rel_path) + r = self._session.delete(url, params=params, headers=headers) + if raise_for_status: + r.raise_for_status() + return HttpResponse.from_requests_lib(r) + + def get( + self, + rel_path: str, + *, + raise_for_status: bool = True, + params: Optional[Dict[str,str]] = None, + headers: Optional[Dict[str,str]] = None, + ) -> HttpResponse: + """ + Sends a ``GET`` request and returns the response. + + Args: + rel_path: The relative path for the request + raise_for_status: Whether to raise an error if response has a non-success HTTP status code + params: Optional dict of URL-encoded parameters + headers: Optional additional headers added to the request + + Return: + The HTTP response. + """ + url = url_join(self.base_url, rel_path) + r = self._session.get(url, params=params, headers=headers) + if raise_for_status: + r.raise_for_status() + return HttpResponse.from_requests_lib(r) + + def post_json( + self, + rel_path: str, + json: Any, + *, + raise_for_status: bool = True, + params: Optional[Dict[str, str]] = None, + headers: Optional[Dict[str, str]] = None, + ) -> HttpResponse: + """ + Sends a ``POST`` request, using the given JSON-compatible object as the request body, and returns the response. + + Args: + rel_path: The relative path for the request + json: The object to use as the request body. + raise_for_status: Whether to raise an error if response has a non-success HTTP status code + params: Optional dict of URL-encoded parameters + headers: Optional additional headers added to the request + + Return: + The HTTP response. + """ + url = url_join(self.base_url, rel_path) + r = self._session.post(url, json=json, params=params, headers=headers) + if raise_for_status: + r.raise_for_status() + return HttpResponse.from_requests_lib(r) diff --git a/gvm/http/core/headers.py b/gvm/http/core/headers.py new file mode 100644 index 00000000..1b5993f2 --- /dev/null +++ b/gvm/http/core/headers.py @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later +from dataclasses import dataclass +from typing import Self, Dict, Optional + + +@dataclass +class ContentType: + """ + Class representing the content type of a HTTP response. + """ + + media_type: str + "The MIME media type, e.g. \"application/json\"" + + params: Dict[str,str] + "Dictionary of parameters in the content type header" + + charset: Optional[str] + "The charset parameter in the content type header if it is set" + + @classmethod + def from_string( + cls, + header_string: str, + fallback_media_type: Optional[str] = "application/octet-stream" + ) -> Self: + """ + Parse the content of content type header into a ContentType object. + + Args: + header_string: The string to parse + fallback_media_type: The media type to use if the `header_string` is `None` or empty. + """ + media_type = fallback_media_type + params = {} + charset = None + + if header_string: + parts = header_string.split(";") + media_type = parts[0].strip() + for param in parts[1:]: + param = param.strip() + if "=" in param: + key, value = map(lambda x: x.strip(), param.split("=", 1)) + params[key] = value + if key == 'charset': + charset = value + else: + params[param] = True + + return ContentType(media_type=media_type, params=params, charset=charset) diff --git a/gvm/http/core/response.py b/gvm/http/core/response.py new file mode 100644 index 00000000..ffb6da64 --- /dev/null +++ b/gvm/http/core/response.py @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later +from dataclasses import dataclass +from typing import Any, Dict, Self, Optional +from requests import Request, Response + +from gvm.http.core.headers import ContentType + + +@dataclass +class HttpResponse: + """ + Class representing an HTTP response. + """ + body: Any + status: int + headers: Dict[str, str] + content_type: Optional[ContentType] + + @classmethod + def from_requests_lib(cls, r: Response) -> Self: + """ + Creates a new HTTP response object from a Request object created by the "Requests" library. + + Args: + r: The request object to convert. + + Return: + A HttpResponse object representing the response. + + An empty body is represented by None. + If the content-type header in the response is set to 'application/json'. + A non-empty body will be parsed accordingly. + """ + ct = ContentType.from_string(r.headers.get('content-type')) + body = r.content + + if r.content == b'': + body = None + elif ct is not None: + if ct.media_type.lower() == 'application/json': + body = r.json() + + return HttpResponse(body, r.status_code, r.headers, ct) diff --git a/gvm/http/openvasd/__init__.py b/gvm/http/openvasd/__init__.py new file mode 100644 index 00000000..9c0a68e7 --- /dev/null +++ b/gvm/http/openvasd/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/gvm/http/openvasd/openvasd1.py b/gvm/http/openvasd/openvasd1.py new file mode 100644 index 00000000..c69cc957 --- /dev/null +++ b/gvm/http/openvasd/openvasd1.py @@ -0,0 +1,333 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import urllib.parse +from typing import Optional, Any + +from gvm.errors import InvalidArgumentType + +from gvm.http.core.api import GvmHttpApi +from gvm.http.core.connector import HttpApiConnector +from gvm.http.core.response import HttpResponse + + +class OpenvasdHttpApiV1(GvmHttpApi): + """ + Class for sending requests to a version 1 openvasd API. + """ + + def __init__( + self, + connector: HttpApiConnector, + *, + api_key: Optional[str] = None, + ): + """ + Create a new openvasd HTTP API instance. + + Args: + connector: The connector handling the HTTP(S) connection + api_key: Optional API key for authentication + default_raise_for_status: whether to raise an exception if HTTP status is not a success + """ + super().__init__(connector, api_key=api_key) + if api_key: + connector.update_headers({"X-API-KEY": api_key}) + + def get_health_alive(self, *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets the "alive" health status of the scanner. + + Args: + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See GET /health/alive in the openvasd API documentation. + """ + return self._connector.get("/health/alive", raise_for_status=raise_for_status) + + def get_health_ready(self, *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets the "ready" health status of the scanner. + + Args: + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See GET /health/ready in the openvasd API documentation. + """ + return self._connector.get("/health/ready", raise_for_status=raise_for_status) + + def get_health_started(self, *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets the "started" health status of the scanner. + + Args: + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See GET /health/started in the openvasd API documentation. + """ + return self._connector.get("/health/started", raise_for_status=raise_for_status) + + def get_notus_os_list(self, *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets the list of operating systems available in Notus. + + Args: + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See GET /notus in the openvasd API documentation. + """ + return self._connector.get("/notus", raise_for_status=raise_for_status) + + def run_notus_scan(self, os: str, package_list: list[str], *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets the Notus results for a given operating system and list of packages. + + Args: + os: Name of the operating system as returned in the list returned by get_notus_os_products. + package_list: List of package names to check. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See POST /notus/{os} in the openvasd API documentation. + """ + quoted_os = urllib.parse.quote(os) + return self._connector.post_json(f"/notus/{quoted_os}", package_list, raise_for_status=raise_for_status) + + def get_scan_preferences(self, *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets the list of available scan preferences. + + Args: + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See POST /scans/preferences in the openvasd API documentation. + """ + return self._connector.get("/scans/preferences", raise_for_status=raise_for_status) + + def create_scan( + self, + target: dict[str, Any], + vt_selection: dict[str, Any], + scanner_params: Optional[dict[str, Any]] = None, + *, + raise_for_status: bool = False + ) -> HttpResponse: + """ + Creates a new scan without starting it. + + See POST /scans in the openvasd API documentation for the expected format of the parameters. + + Args: + target: The target definition for the scan. + vt_selection: The VT selection for the scan, including VT preferences. + scanner_params: The optional scanner parameters. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See POST /scans in the openvasd API documentation. + """ + request_json: dict = { + "target": target, + "vts": vt_selection, + } + if scanner_params: + request_json["scan_preferences"] = scanner_params + return self._connector.post_json("/scans", request_json, raise_for_status=raise_for_status) + + def delete_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResponse: + """ + Deletes a scan with the given id. + + Args: + scan_id: The id of the scan to perform the action on. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See DELETE /scans/{id} in the openvasd API documentation. + """ + quoted_scan_id = urllib.parse.quote(scan_id) + return self._connector.delete(f"/scans/{quoted_scan_id}", raise_for_status=raise_for_status) + + def get_scans(self, *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets the list of available scans. + + Args: + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See GET /scans in the openvasd API documentation. + """ + return self._connector.get("/scans", raise_for_status=raise_for_status) + + def get_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets a scan with the given id. + + Args: + scan_id: The id of the scan to get. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See GET /scans/{id} in the openvasd API documentation. + """ + quoted_scan_id = urllib.parse.quote(scan_id) + return self._connector.get(f"/scans/{quoted_scan_id}", raise_for_status=raise_for_status) + + def get_scan_results( + self, + scan_id: str, + range_start: Optional[int] = None, + range_end: Optional[int] = None, + *, + raise_for_status: bool = False + ) -> HttpResponse: + """ + Gets results of a scan with the given id. + + Args: + scan_id: The id of the scan to get the results of. + range_start: Optional index of the first result to get. + range_end: Optional index of the last result to get. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See GET /scans/{id}/results in the openvasd API documentation. + """ + quoted_scan_id = urllib.parse.quote(scan_id) + params = {} + if range_start is not None: + if not isinstance(range_start, int): + raise InvalidArgumentType(argument="range_start", function=self.get_scan_results.__name__) + + if range_end is not None: + if not isinstance(range_end, int): + raise InvalidArgumentType(argument="range_end", function=self.get_scan_results.__name__) + params["range"] = f"{range_start}-{range_end}" + else: + params["range"] = str(range_start) + else: + if range_end is not None: + if not isinstance(range_end, int): + raise InvalidArgumentType(argument="range_end", function=self.get_scan_results.__name__) + params["range"] = f"0-{range_end}" + + return self._connector.get( + f"/scans/{quoted_scan_id}/results", + params=params, + raise_for_status=raise_for_status + ) + + def get_scan_result( + self, + scan_id: str, + result_id: str|int, + *, + raise_for_status: bool = False + ) -> HttpResponse: + """ + Gets a single result of a scan with the given id. + + Args: + scan_id: The id of the scan to get the results of. + result_id: The id of the result to get. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See GET /scans/{id}/{rid} in the openvasd API documentation. + """ + quoted_scan_id = urllib.parse.quote(scan_id) + quoted_result_id = urllib.parse.quote(str(result_id)) + + return self._connector.get( + f"/scans/{quoted_scan_id}/results/{quoted_result_id}", + raise_for_status=raise_for_status + ) + + def get_scan_status(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets a scan with the given id. + + Args: + scan_id: The id of the scan to get the status of. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See GET /scans/{id}/{rid} in the openvasd API documentation. + """ + quoted_scan_id = urllib.parse.quote(scan_id) + return self._connector.get(f"/scans/{quoted_scan_id}/status", raise_for_status=raise_for_status) + + def run_scan_action(self, scan_id: str, scan_action: str, *, raise_for_status: bool = False) -> HttpResponse: + """ + Performs an action like starting or stopping on a scan with the given id. + + Args: + scan_id: The id of the scan to perform the action on. + scan_action: The action to perform. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See POST /scans/{id} in the openvasd API documentation. + """ + quoted_scan_id = urllib.parse.quote(scan_id) + action_json = { "action": scan_action } + return self._connector.post_json(f"/scans/{quoted_scan_id}", action_json, raise_for_status=raise_for_status) + + def start_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResponse: + """ + Starts a scan with the given id. + + Args: + scan_id: The id of the scan to perform the action on. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See POST /scans/{id} in the openvasd API documentation. + """ + return self.run_scan_action(scan_id, "start", raise_for_status=raise_for_status) + + def stop_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResponse: + """ + Stops a scan with the given id. + + Args: + scan_id: The id of the scan to perform the action on. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See POST /scans/{id} in the openvasd API documentation. + """ + return self.run_scan_action(scan_id, "stop", raise_for_status=raise_for_status) + + def get_vts(self, *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets a list of available vulnerability tests (VTs) on the scanner. + + Args: + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See GET /vts in the openvasd API documentation. + """ + return self._connector.get("/vts", raise_for_status=raise_for_status) + + def get_vt(self, oid: str, *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets the details of a vulnerability test (VT). + + Args: + oid: OID of the VT to get. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See DELETE /scans/{id} in the openvasd API documentation. + """ + quoted_oid = urllib.parse.quote(oid) + return self._connector.get(f"/vts/{quoted_oid}", raise_for_status=raise_for_status) diff --git a/poetry.lock b/poetry.lock index c8994d93..05f5c7a7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "alabaster" @@ -350,7 +350,7 @@ version = "3.4.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, @@ -468,7 +468,7 @@ description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["dev"] -markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" +markers = "sys_platform == \"win32\" or platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -804,7 +804,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -1443,7 +1443,7 @@ version = "2.32.4" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, @@ -1825,7 +1825,7 @@ version = "2.5.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, diff --git a/pyproject.toml b/pyproject.toml index 0e808b3d..50d8aa2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ packages = [{ include = "gvm" }, { include = "tests", format = "sdist" }] python = "^3.9.2" paramiko = ">=2.7.1" lxml = ">=4.5.0" +requests = "^2.32.3" [tool.poetry.group.dev.dependencies] coverage = ">=7.2" diff --git a/tests/http/__init__.py b/tests/http/__init__.py new file mode 100644 index 00000000..9c0a68e7 --- /dev/null +++ b/tests/http/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/tests/http/core/__init__.py b/tests/http/core/__init__.py new file mode 100644 index 00000000..9c0a68e7 --- /dev/null +++ b/tests/http/core/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/tests/http/core/test_api.py b/tests/http/core/test_api.py new file mode 100644 index 00000000..ef48c96a --- /dev/null +++ b/tests/http/core/test_api.py @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import unittest +from unittest.mock import patch, MagicMock + +from gvm.http.core.api import GvmHttpApi + + +class GvmHttpApiTestCase(unittest.TestCase): + # pylint: disable=protected-access + + @patch('gvm.http.core.connector.HttpApiConnector') + def test_basic_init(self, connector_mock: MagicMock): + api = GvmHttpApi(connector_mock) + self.assertEqual(connector_mock, api._connector) + + @patch('gvm.http.core.connector.HttpApiConnector') + def test_init_with_key(self, connector_mock: MagicMock): + api = GvmHttpApi(connector_mock, api_key="my-api-key") + self.assertEqual(connector_mock, api._connector) + self.assertEqual("my-api-key", api._api_key) diff --git a/tests/http/core/test_connector.py b/tests/http/core/test_connector.py new file mode 100644 index 00000000..3f2cfb28 --- /dev/null +++ b/tests/http/core/test_connector.py @@ -0,0 +1,394 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later +import json +import unittest +from http import HTTPStatus +from typing import Optional, Any +from unittest.mock import patch, MagicMock, Mock +from requests.exceptions import HTTPError + +from gvm.http.core.connector import HttpApiConnector, url_join +import requests as requests_lib + +from gvm.http.core.headers import ContentType + + +TEST_JSON_HEADERS = { + "content-type": "application/json;charset=utf-8", + "x-example": "some-test-header" +} + +TEST_JSON_CONTENT_TYPE = ContentType( + media_type="application/json", + params={"charset": "utf-8"}, + charset="utf-8", +) + +TEST_EMPTY_CONTENT_TYPE = ContentType( + media_type="application/octet-stream", + params={}, + charset=None, +) + +TEST_JSON_RESPONSE_BODY = {"response_content": True} + +TEST_JSON_REQUEST_BODY = {"request_number": 5} + + +def new_mock_empty_response( + status: Optional[int | HTTPStatus] = None, +) -> requests_lib.Response: + # pylint: disable=protected-access + response = requests_lib.Response() + response._content = b'' + if status is None: + response.status_code = int(HTTPStatus.NO_CONTENT) + else: + response.status_code = int(status) + return response + + +def new_mock_json_response( + content: Optional[Any] = None, + status: Optional[int|HTTPStatus] = None, +)-> requests_lib.Response: + # pylint: disable=protected-access + response = requests_lib.Response() + response._content = json.dumps(content).encode() + + if status is None: + response.status_code = int(HTTPStatus.OK) + else: + response.status_code = int(status) + + response.headers.update(TEST_JSON_HEADERS) + return response + + +def new_mock_session( + *, + headers: Optional[dict] = None +) -> Mock: + mock = Mock(spec=requests_lib.Session) + mock.headers = headers if headers is not None else {} + return mock + + +class HttpApiConnectorTestCase(unittest.TestCase): + # pylint: disable=protected-access + + def test_url_join(self): + self.assertEqual( + "http://localhost/foo/bar/baz", + url_join("http://localhost/foo", "bar/baz") + ) + self.assertEqual( + "http://localhost/foo/bar/baz", + url_join("http://localhost/foo/", "bar/baz") + ) + self.assertEqual( + "http://localhost/foo/bar/baz", + url_join("http://localhost/foo", "./bar/baz") + ) + self.assertEqual( + "http://localhost/foo/bar/baz", + url_join("http://localhost/foo/", "./bar/baz") + ) + self.assertEqual( + "http://localhost/bar/baz", + url_join("http://localhost/foo", "../bar/baz") + ) + self.assertEqual( + "http://localhost/bar/baz", + url_join("http://localhost/foo", "../bar/baz") + ) + + def test_new_session(self): + new_session = HttpApiConnector._new_session() + self.assertIsInstance(new_session, requests_lib.Session) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_basic_init( + self, + new_session_mock: MagicMock + ): + mock_session = new_session_mock.return_value = new_mock_session() + + connector = HttpApiConnector("http://localhost") + + self.assertEqual("http://localhost", connector.base_url) + self.assertEqual(mock_session, connector._session) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_https_init(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + connector = HttpApiConnector( + "https://localhost", + server_ca_path="foo.crt", + client_cert_paths="bar.key" + ) + + self.assertEqual("https://localhost", connector.base_url) + self.assertEqual(mock_session, connector._session) + self.assertEqual("foo.crt", mock_session.verify) + self.assertEqual("bar.key", mock_session.cert) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_update_headers(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + connector = HttpApiConnector( + "http://localhost", + ) + connector.update_headers({"x-foo": "bar"}) + connector.update_headers({"x-baz": "123"}) + + self.assertEqual({"x-foo": "bar", "x-baz": "123"}, mock_session.headers) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_delete(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.delete.return_value = new_mock_json_response(TEST_JSON_RESPONSE_BODY) + connector = HttpApiConnector("https://localhost") + response = connector.delete("foo", params={"bar": "123"}, headers={"baz": "456"}) + + self.assertEqual(int(HTTPStatus.OK), response.status) + self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) + self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) + self.assertEqual(TEST_JSON_HEADERS, response.headers) + + mock_session.delete.assert_called_once_with( + 'https://localhost/foo', + params={"bar": "123"}, + headers={"baz": "456"} + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_minimal_delete(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.delete.return_value = new_mock_empty_response() + connector = HttpApiConnector("https://localhost") + response = connector.delete("foo") + + self.assertEqual(int(HTTPStatus.NO_CONTENT), response.status) + self.assertEqual(None, response.body) + self.assertEqual(TEST_EMPTY_CONTENT_TYPE, response.content_type) + self.assertEqual({}, response.headers) + + mock_session.delete.assert_called_once_with( + 'https://localhost/foo', params=None, headers=None + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_delete_raise_on_status(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.delete.return_value = new_mock_empty_response(HTTPStatus.INTERNAL_SERVER_ERROR) + connector = HttpApiConnector("https://localhost") + self.assertRaises( + HTTPError, + connector.delete, + "foo" + ) + + mock_session.delete.assert_called_once_with( + 'https://localhost/foo', params=None, headers=None + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_delete_no_raise_on_status(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.delete.return_value = new_mock_json_response( + content=TEST_JSON_RESPONSE_BODY, + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + connector = HttpApiConnector("https://localhost") + response = connector.delete( + "foo", + params={"bar": "123"}, + headers={"baz": "456"}, + raise_for_status=False + ) + + self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) + self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) + self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) + self.assertEqual(TEST_JSON_HEADERS, response.headers) + + mock_session.delete.assert_called_once_with( + 'https://localhost/foo', + params={"bar": "123"}, + headers={"baz": "456"} + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_get(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.get.return_value = new_mock_json_response(TEST_JSON_RESPONSE_BODY) + connector = HttpApiConnector("https://localhost") + response = connector.get("foo", params={"bar": "123"}, headers={"baz": "456"}) + + self.assertEqual(int(HTTPStatus.OK), response.status) + self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) + self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) + self.assertEqual(TEST_JSON_HEADERS, response.headers) + + mock_session.get.assert_called_once_with( + 'https://localhost/foo', + params={"bar": "123"}, + headers={"baz": "456"} + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_minimal_get(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.get.return_value = new_mock_empty_response() + connector = HttpApiConnector("https://localhost") + response = connector.get("foo") + + self.assertEqual(int(HTTPStatus.NO_CONTENT), response.status) + self.assertEqual(None, response.body) + self.assertEqual(TEST_EMPTY_CONTENT_TYPE, response.content_type) + self.assertEqual({}, response.headers) + + mock_session.get.assert_called_once_with( + 'https://localhost/foo', params=None, headers=None + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_get_raise_on_status(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.get.return_value = new_mock_empty_response(HTTPStatus.INTERNAL_SERVER_ERROR) + connector = HttpApiConnector("https://localhost") + self.assertRaises( + HTTPError, + connector.get, + "foo" + ) + + mock_session.get.assert_called_once_with( + 'https://localhost/foo', params=None, headers=None + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_get_no_raise_on_status(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.get.return_value = new_mock_json_response( + content=TEST_JSON_RESPONSE_BODY, + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + connector = HttpApiConnector("https://localhost") + response = connector.get( + "foo", + params={"bar": "123"}, + headers={"baz": "456"}, + raise_for_status=False + ) + + self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) + self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) + self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) + self.assertEqual(TEST_JSON_HEADERS, response.headers) + + mock_session.get.assert_called_once_with( + 'https://localhost/foo', + params={"bar": "123"}, + headers={"baz": "456"} + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_post_json(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.post.return_value = new_mock_json_response(TEST_JSON_RESPONSE_BODY) + connector = HttpApiConnector("https://localhost") + response = connector.post_json( + "foo", + json={"number": 5}, + params={"bar": "123"}, + headers={"baz": "456"} + ) + + self.assertEqual(int(HTTPStatus.OK), response.status) + self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) + self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) + self.assertEqual(TEST_JSON_HEADERS, response.headers) + + mock_session.post.assert_called_once_with( + 'https://localhost/foo', + json={"number": 5}, + params={"bar": "123"}, + headers={"baz": "456"} + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_minimal_post_json(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.post.return_value = new_mock_empty_response() + connector = HttpApiConnector("https://localhost") + response = connector.post_json("foo", TEST_JSON_REQUEST_BODY) + + self.assertEqual(int(HTTPStatus.NO_CONTENT), response.status) + self.assertEqual(None, response.body) + self.assertEqual(TEST_EMPTY_CONTENT_TYPE, response.content_type) + self.assertEqual({}, response.headers) + + mock_session.post.assert_called_once_with( + 'https://localhost/foo', json=TEST_JSON_REQUEST_BODY, params=None, headers=None + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_post_json_raise_on_status(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.post.return_value = new_mock_empty_response(HTTPStatus.INTERNAL_SERVER_ERROR) + connector = HttpApiConnector("https://localhost") + self.assertRaises( + HTTPError, + connector.post_json, + "foo", + json=TEST_JSON_REQUEST_BODY + ) + + mock_session.post.assert_called_once_with( + 'https://localhost/foo', json=TEST_JSON_REQUEST_BODY, params=None, headers=None + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_post_json_no_raise_on_status(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.post.return_value = new_mock_json_response( + content=TEST_JSON_RESPONSE_BODY, + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + connector = HttpApiConnector("https://localhost") + response = connector.post_json( + "foo", + json=TEST_JSON_REQUEST_BODY, + params={"bar": "123"}, + headers={"baz": "456"}, + raise_for_status=False + ) + + self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) + self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) + self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) + self.assertEqual(TEST_JSON_HEADERS, response.headers) + + mock_session.post.assert_called_once_with( + 'https://localhost/foo', + json=TEST_JSON_REQUEST_BODY, + params={"bar": "123"}, + headers={"baz": "456"} + ) \ No newline at end of file diff --git a/tests/http/core/test_headers.py b/tests/http/core/test_headers.py new file mode 100644 index 00000000..032a4301 --- /dev/null +++ b/tests/http/core/test_headers.py @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import unittest + +from gvm.http.core.headers import ContentType + + +class ContentTypeTestCase(unittest.TestCase): + + def test_from_empty_string(self): + ct = ContentType.from_string("") + self.assertEqual("application/octet-stream", ct.media_type) + self.assertEqual({}, ct.params) + self.assertEqual(None, ct.charset) + + def test_from_basic_string(self): + ct = ContentType.from_string("text/html") + self.assertEqual("text/html", ct.media_type) + self.assertEqual({}, ct.params) + self.assertEqual(None, ct.charset) + + def test_from_string_with_charset(self): + ct = ContentType.from_string("text/html; charset=utf-32 ") + self.assertEqual("text/html", ct.media_type) + self.assertEqual({"charset": "utf-32"}, ct.params) + self.assertEqual("utf-32", ct.charset) + + def test_from_string_with_param(self): + ct = ContentType.from_string("multipart/form-data; boundary===boundary==; charset=utf-32 ") + self.assertEqual("multipart/form-data", ct.media_type) + self.assertEqual({"boundary": "==boundary==", "charset": "utf-32"}, ct.params) + self.assertEqual("utf-32", ct.charset) + + def test_from_string_with_valueless_param(self): + ct = ContentType.from_string("text/html; x-foo") + self.assertEqual("text/html", ct.media_type) + self.assertEqual({"x-foo": True}, ct.params) + self.assertEqual(None, ct.charset) diff --git a/tests/http/core/test_response.py b/tests/http/core/test_response.py new file mode 100644 index 00000000..8edf0dda --- /dev/null +++ b/tests/http/core/test_response.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later +import json +import unittest +from http import HTTPStatus + +import requests as requests_lib + +from gvm.http.core.response import HttpResponse + +class HttpResponseFromRequestsLibTestCase(unittest.TestCase): + def test_from_empty_response(self): + requests_response = requests_lib.Response() + requests_response.status_code = int(HTTPStatus.OK) + requests_response._content = b'' + + response = HttpResponse.from_requests_lib(requests_response) + + self.assertIsNone(response.body) + self.assertEqual(int(HTTPStatus.OK), response.status) + self.assertEqual({}, response.headers) + + def test_from_plain_text_response(self): + requests_response = requests_lib.Response() + requests_response.status_code = int(HTTPStatus.OK) + requests_response.headers.update({"content-type": "text/plain"}) + requests_response._content = b'ABCDEF' + + response = HttpResponse.from_requests_lib(requests_response) + + self.assertEqual(b'ABCDEF', response.body) + self.assertEqual(int(HTTPStatus.OK), response.status) + self.assertEqual({"content-type": "text/plain"}, response.headers) + + def test_from_json_response(self): + test_content = {"foo": ["bar", 12345], "baz": True} + requests_response = requests_lib.Response() + requests_response.status_code = int(HTTPStatus.OK) + requests_response.headers.update({"content-type": "application/json"}) + requests_response._content = json.dumps(test_content).encode() + + response = HttpResponse.from_requests_lib(requests_response) + + self.assertEqual(test_content, response.body) + self.assertEqual(int(HTTPStatus.OK), response.status) + self.assertEqual({"content-type": "application/json"}, response.headers) + + def test_from_error_json_response(self): + test_content = {"error": "Internal server error"} + requests_response = requests_lib.Response() + requests_response.status_code = int(HTTPStatus.INTERNAL_SERVER_ERROR) + requests_response.headers.update({"content-type": "application/json"}) + requests_response._content = json.dumps(test_content).encode() + + response = HttpResponse.from_requests_lib(requests_response) + + self.assertEqual(test_content, response.body) + self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) + self.assertEqual({"content-type": "application/json"}, response.headers) diff --git a/tests/http/openvasd/__init__.py b/tests/http/openvasd/__init__.py new file mode 100644 index 00000000..9c0a68e7 --- /dev/null +++ b/tests/http/openvasd/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/tests/http/openvasd/test_openvasd1.py b/tests/http/openvasd/test_openvasd1.py new file mode 100644 index 00000000..a791f8b4 --- /dev/null +++ b/tests/http/openvasd/test_openvasd1.py @@ -0,0 +1,344 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import unittest +from http import HTTPStatus +from typing import Optional +from unittest.mock import Mock, patch + +from gvm.errors import InvalidArgumentType + +from gvm.http.core.headers import ContentType +from gvm.http.core.response import HttpResponse +from gvm.http.openvasd.openvasd1 import OpenvasdHttpApiV1 + + +def new_mock_empty_response( + status: Optional[int | HTTPStatus] = None, + headers: Optional[dict[str, str]] = None, +): + if status is None: + status = int(HTTPStatus.NO_CONTENT) + if headers is None: + headers = [] + content_type = ContentType.from_string(None) + return HttpResponse(body=None, status=status, headers=headers, content_type=content_type) + + +class OpenvasdHttpApiV1TestCase(unittest.TestCase): + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_init(self, mock_connector: Mock): + api = OpenvasdHttpApiV1(mock_connector) + mock_connector.update_headers.assert_not_called() + self.assertIsNotNone(api) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_init_with_api_key(self, mock_connector: Mock): + api = OpenvasdHttpApiV1(mock_connector, api_key="my-API-key") + mock_connector.update_headers.assert_called_once_with({"X-API-KEY": "my-API-key"}) + self.assertIsNotNone(api) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_health_alive(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_health_alive() + mock_connector.get.assert_called_once_with("/health/alive", raise_for_status=False) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_health_ready(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_health_ready() + mock_connector.get.assert_called_once_with("/health/ready", raise_for_status=False) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_health_started(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_health_started() + mock_connector.get.assert_called_once_with("/health/started", raise_for_status=False) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_notus_os_list(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_notus_os_list() + mock_connector.get.assert_called_once_with("/notus", raise_for_status=False) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_run_notus_scan(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.post_json.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.run_notus_scan("Debian 11", ["foo-1.0", "bar-0.23"]) + mock_connector.post_json.assert_called_once_with( + "/notus/Debian%2011", + ["foo-1.0", "bar-0.23"], + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_scan_preferences(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_scan_preferences() + mock_connector.get.assert_called_once_with("/scans/preferences", raise_for_status=False) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_create_scan(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.post_json.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + # minimal scan + response = api.create_scan( + {"hosts": "somehost"}, + ["some_vt", "another_vt"], + ) + mock_connector.post_json.assert_called_once_with( + "/scans", + { + "target": {"hosts": "somehost"}, + "vts": ["some_vt", "another_vt"], + }, + raise_for_status=False + ) + + # scan with all options + mock_connector.post_json.reset_mock() + response = api.create_scan( + {"hosts": "somehost"}, + ["some_vt", "another_vt"], + {"my_scanner_param": "abc"}, + ) + mock_connector.post_json.assert_called_once_with( + "/scans", + { + "target": {"hosts": "somehost"}, + "vts": ["some_vt", "another_vt"], + "scan_preferences": {"my_scanner_param": "abc"} + }, + raise_for_status=False + ) + self.assertEqual(expected_response, response) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_delete_scan(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.delete.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.delete_scan("foo bar") + mock_connector.delete.assert_called_once_with("/scans/foo%20bar", raise_for_status=False) + self.assertEqual(expected_response, response) + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_scans(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_scans() + mock_connector.get.assert_called_once_with("/scans", raise_for_status=False) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_scan(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_scan("foo bar") + mock_connector.get.assert_called_once_with("/scans/foo%20bar", raise_for_status=False) + self.assertEqual(expected_response, response) + + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_scan_results(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_scan_results("foo bar") + mock_connector.get.assert_called_once_with( + "/scans/foo%20bar/results", + params={}, + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_scan_results_with_ranges(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + # range start only + response = api.get_scan_results("foo bar", 12) + mock_connector.get.assert_called_once_with( + "/scans/foo%20bar/results", + params={"range": "12"}, + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + # range start and end + mock_connector.get.reset_mock() + response = api.get_scan_results("foo bar", 12, 34) + mock_connector.get.assert_called_once_with( + "/scans/foo%20bar/results", + params={"range": "12-34"}, + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + # range end only + mock_connector.get.reset_mock() + response = api.get_scan_results("foo bar", range_end=23) + mock_connector.get.assert_called_once_with( + "/scans/foo%20bar/results", + params={"range": "0-23"}, + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_scan_results_with_invalid_ranges(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + # range start + self.assertRaises( + InvalidArgumentType, + api.get_scan_results, + "foo bar", "invalid" + ) + + # range start and end + self.assertRaises( + InvalidArgumentType, + api.get_scan_results, + "foo bar", 12, "invalid" + ) + + # range end only + self.assertRaises( + InvalidArgumentType, + api.get_scan_results, + "foo bar", range_end="invalid" + ) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_scan_result(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_scan_result("foo bar", "baz qux") + mock_connector.get.assert_called_once_with( + "/scans/foo%20bar/results/baz%20qux", + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_scan_status(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_scan_status("foo bar") + mock_connector.get.assert_called_once_with( + "/scans/foo%20bar/status", + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_run_scan_action(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.post_json.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.run_scan_action("foo bar", "do-something") + mock_connector.post_json.assert_called_once_with( + "/scans/foo%20bar", + {"action": "do-something"}, + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_start_scan(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.post_json.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.start_scan("foo bar") + mock_connector.post_json.assert_called_once_with( + "/scans/foo%20bar", + {"action": "start"}, + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_stop_scan(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.post_json.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.stop_scan("foo bar") + mock_connector.post_json.assert_called_once_with( + "/scans/foo%20bar", + {"action": "stop"}, + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_vts(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_vts() + mock_connector.get.assert_called_once_with( + "/vts", + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_vt(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_vt("foo bar") + mock_connector.get.assert_called_once_with( + "/vts/foo%20bar", + raise_for_status=False + ) + self.assertEqual(expected_response, response) From f83a1d049a1d3a82f446557bc3a76a22546cbe4a Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 08:32:09 +0200 Subject: [PATCH 02/34] Add documentation for HTTP APIs, small refactoring Sphinx documentation pages and some additional docstrings have been added. The "api" module in gvm.http.core is renamed to "_api" and url_join is now a class method of HttpApiConnector. --- docs/api/http.rst | 12 ++++++++++ docs/api/httpcore.rst | 28 +++++++++++++++++++++++ docs/api/openvasdv1.rst | 9 ++++++++ gvm/http/__init__.py | 8 +++++++ gvm/http/core/__init__.py | 4 ++++ gvm/http/core/{api.py => _api.py} | 4 ++++ gvm/http/core/connector.py | 37 +++++++++++++++++-------------- gvm/http/core/headers.py | 5 +++++ gvm/http/core/response.py | 12 ++++++++++ gvm/http/openvasd/__init__.py | 5 +++++ gvm/http/openvasd/openvasd1.py | 7 ++++-- tests/http/core/test_api.py | 2 +- tests/http/core/test_connector.py | 14 ++++++------ 13 files changed, 120 insertions(+), 27 deletions(-) create mode 100644 docs/api/http.rst create mode 100644 docs/api/httpcore.rst create mode 100644 docs/api/openvasdv1.rst rename gvm/http/core/{api.py => _api.py} (94%) diff --git a/docs/api/http.rst b/docs/api/http.rst new file mode 100644 index 00000000..50b52238 --- /dev/null +++ b/docs/api/http.rst @@ -0,0 +1,12 @@ +.. _http: + +HTTP APIs +--------- + +.. automodule:: gvm.http + +.. toctree:: + :maxdepth: 1 + + httpcore + openvasdv1 \ No newline at end of file diff --git a/docs/api/httpcore.rst b/docs/api/httpcore.rst new file mode 100644 index 00000000..f94fb4af --- /dev/null +++ b/docs/api/httpcore.rst @@ -0,0 +1,28 @@ +.. _httpcore: + +HTTP core classes +^^^^^^^^^^^^^^^^^ + +Connector +######### + +.. automodule:: gvm.http.core.connector + +.. autoclass:: HttpApiConnector + :members: + +Headers +####### + +.. automodule:: gvm.http.core.headers + +.. autoclass:: ContentType + :members: + +Response +######## + +.. automodule:: gvm.http.core.response + +.. autoclass:: HttpResponse + :members: \ No newline at end of file diff --git a/docs/api/openvasdv1.rst b/docs/api/openvasdv1.rst new file mode 100644 index 00000000..2a3699c9 --- /dev/null +++ b/docs/api/openvasdv1.rst @@ -0,0 +1,9 @@ +.. _openvasdv1: + +openvasd v1 +^^^^^^^^^^^ + +.. automodule:: gvm.http.openvasd.openvasd1 + +.. autoclass:: OpenvasdHttpApiV1 + :members: \ No newline at end of file diff --git a/gvm/http/__init__.py b/gvm/http/__init__.py index 9c0a68e7..6e5d909d 100644 --- a/gvm/http/__init__.py +++ b/gvm/http/__init__.py @@ -1,3 +1,11 @@ # SPDX-FileCopyrightText: 2025 Greenbone AG # # SPDX-License-Identifier: GPL-3.0-or-later +""" +Package for supported Greenbone HTTP APIs. + +Currently only `openvasd version 1`_ is supported. + +.. _openvasd version 1: + https://greenbone.github.io/scanner-api/#/ +""" \ No newline at end of file diff --git a/gvm/http/core/__init__.py b/gvm/http/core/__init__.py index 9c0a68e7..59c68f9b 100644 --- a/gvm/http/core/__init__.py +++ b/gvm/http/core/__init__.py @@ -1,3 +1,7 @@ # SPDX-FileCopyrightText: 2025 Greenbone AG # # SPDX-License-Identifier: GPL-3.0-or-later + +""" +HTTP core classes +""" \ No newline at end of file diff --git a/gvm/http/core/api.py b/gvm/http/core/_api.py similarity index 94% rename from gvm/http/core/api.py rename to gvm/http/core/_api.py index 4ed4a15f..a068966d 100644 --- a/gvm/http/core/api.py +++ b/gvm/http/core/_api.py @@ -2,6 +2,10 @@ # # SPDX-License-Identifier: GPL-3.0-or-later +""" +Base class module for GVM HTTP APIs +""" + from typing import Optional from gvm.http.core.connector import HttpApiConnector diff --git a/gvm/http/core/connector.py b/gvm/http/core/connector.py index 20bf2270..f45aa849 100644 --- a/gvm/http/core/connector.py +++ b/gvm/http/core/connector.py @@ -2,6 +2,10 @@ # # SPDX-License-Identifier: GPL-3.0-or-later +""" +Module for handling GVM HTTP API connections +""" + import urllib.parse from typing import Optional, Tuple, Dict, Any @@ -9,20 +13,6 @@ from gvm.http.core.response import HttpResponse - -def url_join(base: str, rel_path: str) -> str: - """ - Combines a base URL and a relative path into one URL. - - Unlike `urrlib.parse.urljoin` the base path will always be the parent of the relative path as if it - ends with "/". - """ - if base.endswith("/"): - return urllib.parse.urljoin(base, rel_path) - else: - return urllib.parse.urljoin(base + "/", rel_path) - - class HttpApiConnector: """ Class for connecting to HTTP based API servers, sending requests and receiving the responses. @@ -35,6 +25,19 @@ def _new_session(cls): """ return Session() + @classmethod + def url_join(cls, base: str, rel_path: str) -> str: + """ + Combines a base URL and a relative path into one URL. + + Unlike `urrlib.parse.urljoin` the base path will always be the parent of the relative path as if it + ends with "/". + """ + if base.endswith("/"): + return urllib.parse.urljoin(base, rel_path) + else: + return urllib.parse.urljoin(base + "/", rel_path) + def __init__( self, base_url: str, @@ -93,7 +96,7 @@ def delete( Return: The HTTP response. """ - url = url_join(self.base_url, rel_path) + url = self.url_join(self.base_url, rel_path) r = self._session.delete(url, params=params, headers=headers) if raise_for_status: r.raise_for_status() @@ -119,7 +122,7 @@ def get( Return: The HTTP response. """ - url = url_join(self.base_url, rel_path) + url = self.url_join(self.base_url, rel_path) r = self._session.get(url, params=params, headers=headers) if raise_for_status: r.raise_for_status() @@ -147,7 +150,7 @@ def post_json( Return: The HTTP response. """ - url = url_join(self.base_url, rel_path) + url = self.url_join(self.base_url, rel_path) r = self._session.post(url, json=json, params=params, headers=headers) if raise_for_status: r.raise_for_status() diff --git a/gvm/http/core/headers.py b/gvm/http/core/headers.py index 1b5993f2..3d8f28a3 100644 --- a/gvm/http/core/headers.py +++ b/gvm/http/core/headers.py @@ -1,6 +1,11 @@ # SPDX-FileCopyrightText: 2025 Greenbone AG # # SPDX-License-Identifier: GPL-3.0-or-later + +""" +Module for handling special HTTP headers +""" + from dataclasses import dataclass from typing import Self, Dict, Optional diff --git a/gvm/http/core/response.py b/gvm/http/core/response.py index ffb6da64..81709e40 100644 --- a/gvm/http/core/response.py +++ b/gvm/http/core/response.py @@ -1,6 +1,11 @@ # SPDX-FileCopyrightText: 2025 Greenbone AG # # SPDX-License-Identifier: GPL-3.0-or-later + +""" +Module for abstracting HTTP responses +""" + from dataclasses import dataclass from typing import Any, Dict, Self, Optional from requests import Request, Response @@ -14,9 +19,16 @@ class HttpResponse: Class representing an HTTP response. """ body: Any + "The body of the response" + status: int + "HTTP status code of the response" + headers: Dict[str, str] + "Dict containing the headers of the response" + content_type: Optional[ContentType] + "The content type of the response if it was included in the headers" @classmethod def from_requests_lib(cls, r: Response) -> Self: diff --git a/gvm/http/openvasd/__init__.py b/gvm/http/openvasd/__init__.py index 9c0a68e7..6769b5a9 100644 --- a/gvm/http/openvasd/__init__.py +++ b/gvm/http/openvasd/__init__.py @@ -1,3 +1,8 @@ # SPDX-FileCopyrightText: 2025 Greenbone AG # # SPDX-License-Identifier: GPL-3.0-or-later +""" +Package for sending openvasd and handling the responses of HTTP API requests. + +* :class:`OpenvasdHttpApiV1` - openvasd version 1 +""" \ No newline at end of file diff --git a/gvm/http/openvasd/openvasd1.py b/gvm/http/openvasd/openvasd1.py index c69cc957..e7b8e24d 100644 --- a/gvm/http/openvasd/openvasd1.py +++ b/gvm/http/openvasd/openvasd1.py @@ -2,12 +2,16 @@ # # SPDX-License-Identifier: GPL-3.0-or-later +""" +openvasd HTTP API version 1 +""" + import urllib.parse from typing import Optional, Any from gvm.errors import InvalidArgumentType -from gvm.http.core.api import GvmHttpApi +from gvm.http.core._api import GvmHttpApi from gvm.http.core.connector import HttpApiConnector from gvm.http.core.response import HttpResponse @@ -29,7 +33,6 @@ def __init__( Args: connector: The connector handling the HTTP(S) connection api_key: Optional API key for authentication - default_raise_for_status: whether to raise an exception if HTTP status is not a success """ super().__init__(connector, api_key=api_key) if api_key: diff --git a/tests/http/core/test_api.py b/tests/http/core/test_api.py index ef48c96a..3cfaa2e6 100644 --- a/tests/http/core/test_api.py +++ b/tests/http/core/test_api.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import patch, MagicMock -from gvm.http.core.api import GvmHttpApi +from gvm.http.core._api import GvmHttpApi class GvmHttpApiTestCase(unittest.TestCase): diff --git a/tests/http/core/test_connector.py b/tests/http/core/test_connector.py index 3f2cfb28..83ba7adf 100644 --- a/tests/http/core/test_connector.py +++ b/tests/http/core/test_connector.py @@ -8,7 +8,7 @@ from unittest.mock import patch, MagicMock, Mock from requests.exceptions import HTTPError -from gvm.http.core.connector import HttpApiConnector, url_join +from gvm.http.core.connector import HttpApiConnector import requests as requests_lib from gvm.http.core.headers import ContentType @@ -81,27 +81,27 @@ class HttpApiConnectorTestCase(unittest.TestCase): def test_url_join(self): self.assertEqual( "http://localhost/foo/bar/baz", - url_join("http://localhost/foo", "bar/baz") + HttpApiConnector.url_join("http://localhost/foo", "bar/baz") ) self.assertEqual( "http://localhost/foo/bar/baz", - url_join("http://localhost/foo/", "bar/baz") + HttpApiConnector.url_join("http://localhost/foo/", "bar/baz") ) self.assertEqual( "http://localhost/foo/bar/baz", - url_join("http://localhost/foo", "./bar/baz") + HttpApiConnector.url_join("http://localhost/foo", "./bar/baz") ) self.assertEqual( "http://localhost/foo/bar/baz", - url_join("http://localhost/foo/", "./bar/baz") + HttpApiConnector.url_join("http://localhost/foo/", "./bar/baz") ) self.assertEqual( "http://localhost/bar/baz", - url_join("http://localhost/foo", "../bar/baz") + HttpApiConnector.url_join("http://localhost/foo", "../bar/baz") ) self.assertEqual( "http://localhost/bar/baz", - url_join("http://localhost/foo", "../bar/baz") + HttpApiConnector.url_join("http://localhost/foo", "../bar/baz") ) def test_new_session(self): From 0340b84e5aeb1ac8d9ebbaca4b6c4c37087d18ff Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 08:41:55 +0200 Subject: [PATCH 03/34] Reformat HTTP package and tests --- gvm/http/__init__.py | 3 +- gvm/http/core/__init__.py | 2 +- gvm/http/core/_api.py | 4 +- gvm/http/core/connector.py | 19 +-- gvm/http/core/headers.py | 12 +- gvm/http/core/response.py | 7 +- gvm/http/openvasd/__init__.py | 3 +- gvm/http/openvasd/openvasd1.py | 141 ++++++++++++++++------ tests/http/core/test_api.py | 4 +- tests/http/core/test_connector.py | 167 +++++++++++++------------- tests/http/core/test_headers.py | 8 +- tests/http/core/test_response.py | 7 +- tests/http/openvasd/test_openvasd1.py | 97 ++++++++------- 13 files changed, 284 insertions(+), 190 deletions(-) diff --git a/gvm/http/__init__.py b/gvm/http/__init__.py index 6e5d909d..87e62d3f 100644 --- a/gvm/http/__init__.py +++ b/gvm/http/__init__.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2025 Greenbone AG # # SPDX-License-Identifier: GPL-3.0-or-later + """ Package for supported Greenbone HTTP APIs. @@ -8,4 +9,4 @@ .. _openvasd version 1: https://greenbone.github.io/scanner-api/#/ -""" \ No newline at end of file +""" diff --git a/gvm/http/core/__init__.py b/gvm/http/core/__init__.py index 59c68f9b..d079cfd4 100644 --- a/gvm/http/core/__init__.py +++ b/gvm/http/core/__init__.py @@ -4,4 +4,4 @@ """ HTTP core classes -""" \ No newline at end of file +""" diff --git a/gvm/http/core/_api.py b/gvm/http/core/_api.py index a068966d..bd7a5c1f 100644 --- a/gvm/http/core/_api.py +++ b/gvm/http/core/_api.py @@ -16,7 +16,9 @@ class GvmHttpApi: Base class for HTTP-based GVM APIs. """ - def __init__(self, connector: HttpApiConnector, *, api_key: Optional[str] = None): + def __init__( + self, connector: HttpApiConnector, *, api_key: Optional[str] = None + ): """ Create a new generic GVM HTTP API instance. diff --git a/gvm/http/core/connector.py b/gvm/http/core/connector.py index f45aa849..e683edf7 100644 --- a/gvm/http/core/connector.py +++ b/gvm/http/core/connector.py @@ -13,6 +13,7 @@ from gvm.http.core.response import HttpResponse + class HttpApiConnector: """ Class for connecting to HTTP based API servers, sending requests and receiving the responses. @@ -39,11 +40,11 @@ def url_join(cls, base: str, rel_path: str) -> str: return urllib.parse.urljoin(base + "/", rel_path) def __init__( - self, - base_url: str, - *, - server_ca_path: Optional[str] = None, - client_cert_paths: Optional[str | Tuple[str]] = None, + self, + base_url: str, + *, + server_ca_path: Optional[str] = None, + client_cert_paths: Optional[str | Tuple[str]] = None, ): """ Create a new HTTP API Connector. @@ -81,8 +82,8 @@ def delete( rel_path: str, *, raise_for_status: bool = True, - params: Optional[Dict[str,str]] = None, - headers: Optional[Dict[str,str]] = None, + params: Optional[Dict[str, str]] = None, + headers: Optional[Dict[str, str]] = None, ) -> HttpResponse: """ Sends a ``DELETE`` request and returns the response. @@ -107,8 +108,8 @@ def get( rel_path: str, *, raise_for_status: bool = True, - params: Optional[Dict[str,str]] = None, - headers: Optional[Dict[str,str]] = None, + params: Optional[Dict[str, str]] = None, + headers: Optional[Dict[str, str]] = None, ) -> HttpResponse: """ Sends a ``GET`` request and returns the response. diff --git a/gvm/http/core/headers.py b/gvm/http/core/headers.py index 3d8f28a3..6d384049 100644 --- a/gvm/http/core/headers.py +++ b/gvm/http/core/headers.py @@ -17,9 +17,9 @@ class ContentType: """ media_type: str - "The MIME media type, e.g. \"application/json\"" + 'The MIME media type, e.g. "application/json"' - params: Dict[str,str] + params: Dict[str, str] "Dictionary of parameters in the content type header" charset: Optional[str] @@ -29,7 +29,7 @@ class ContentType: def from_string( cls, header_string: str, - fallback_media_type: Optional[str] = "application/octet-stream" + fallback_media_type: Optional[str] = "application/octet-stream", ) -> Self: """ Parse the content of content type header into a ContentType object. @@ -50,9 +50,11 @@ def from_string( if "=" in param: key, value = map(lambda x: x.strip(), param.split("=", 1)) params[key] = value - if key == 'charset': + if key == "charset": charset = value else: params[param] = True - return ContentType(media_type=media_type, params=params, charset=charset) + return ContentType( + media_type=media_type, params=params, charset=charset + ) diff --git a/gvm/http/core/response.py b/gvm/http/core/response.py index 81709e40..30b3fb97 100644 --- a/gvm/http/core/response.py +++ b/gvm/http/core/response.py @@ -18,6 +18,7 @@ class HttpResponse: """ Class representing an HTTP response. """ + body: Any "The body of the response" @@ -45,13 +46,13 @@ def from_requests_lib(cls, r: Response) -> Self: If the content-type header in the response is set to 'application/json'. A non-empty body will be parsed accordingly. """ - ct = ContentType.from_string(r.headers.get('content-type')) + ct = ContentType.from_string(r.headers.get("content-type")) body = r.content - if r.content == b'': + if r.content == b"": body = None elif ct is not None: - if ct.media_type.lower() == 'application/json': + if ct.media_type.lower() == "application/json": body = r.json() return HttpResponse(body, r.status_code, r.headers, ct) diff --git a/gvm/http/openvasd/__init__.py b/gvm/http/openvasd/__init__.py index 6769b5a9..251ddb41 100644 --- a/gvm/http/openvasd/__init__.py +++ b/gvm/http/openvasd/__init__.py @@ -1,8 +1,9 @@ # SPDX-FileCopyrightText: 2025 Greenbone AG # # SPDX-License-Identifier: GPL-3.0-or-later + """ Package for sending openvasd and handling the responses of HTTP API requests. * :class:`OpenvasdHttpApiV1` - openvasd version 1 -""" \ No newline at end of file +""" diff --git a/gvm/http/openvasd/openvasd1.py b/gvm/http/openvasd/openvasd1.py index e7b8e24d..86526a72 100644 --- a/gvm/http/openvasd/openvasd1.py +++ b/gvm/http/openvasd/openvasd1.py @@ -38,7 +38,9 @@ def __init__( if api_key: connector.update_headers({"X-API-KEY": api_key}) - def get_health_alive(self, *, raise_for_status: bool = False) -> HttpResponse: + def get_health_alive( + self, *, raise_for_status: bool = False + ) -> HttpResponse: """ Gets the "alive" health status of the scanner. @@ -48,9 +50,13 @@ def get_health_alive(self, *, raise_for_status: bool = False) -> HttpResponse: Return: The HTTP response. See GET /health/alive in the openvasd API documentation. """ - return self._connector.get("/health/alive", raise_for_status=raise_for_status) + return self._connector.get( + "/health/alive", raise_for_status=raise_for_status + ) - def get_health_ready(self, *, raise_for_status: bool = False) -> HttpResponse: + def get_health_ready( + self, *, raise_for_status: bool = False + ) -> HttpResponse: """ Gets the "ready" health status of the scanner. @@ -60,9 +66,13 @@ def get_health_ready(self, *, raise_for_status: bool = False) -> HttpResponse: Return: The HTTP response. See GET /health/ready in the openvasd API documentation. """ - return self._connector.get("/health/ready", raise_for_status=raise_for_status) + return self._connector.get( + "/health/ready", raise_for_status=raise_for_status + ) - def get_health_started(self, *, raise_for_status: bool = False) -> HttpResponse: + def get_health_started( + self, *, raise_for_status: bool = False + ) -> HttpResponse: """ Gets the "started" health status of the scanner. @@ -72,9 +82,13 @@ def get_health_started(self, *, raise_for_status: bool = False) -> HttpResponse: Return: The HTTP response. See GET /health/started in the openvasd API documentation. """ - return self._connector.get("/health/started", raise_for_status=raise_for_status) + return self._connector.get( + "/health/started", raise_for_status=raise_for_status + ) - def get_notus_os_list(self, *, raise_for_status: bool = False) -> HttpResponse: + def get_notus_os_list( + self, *, raise_for_status: bool = False + ) -> HttpResponse: """ Gets the list of operating systems available in Notus. @@ -86,7 +100,13 @@ def get_notus_os_list(self, *, raise_for_status: bool = False) -> HttpResponse: """ return self._connector.get("/notus", raise_for_status=raise_for_status) - def run_notus_scan(self, os: str, package_list: list[str], *, raise_for_status: bool = False) -> HttpResponse: + def run_notus_scan( + self, + os: str, + package_list: list[str], + *, + raise_for_status: bool = False, + ) -> HttpResponse: """ Gets the Notus results for a given operating system and list of packages. @@ -99,9 +119,15 @@ def run_notus_scan(self, os: str, package_list: list[str], *, raise_for_status: The HTTP response. See POST /notus/{os} in the openvasd API documentation. """ quoted_os = urllib.parse.quote(os) - return self._connector.post_json(f"/notus/{quoted_os}", package_list, raise_for_status=raise_for_status) + return self._connector.post_json( + f"/notus/{quoted_os}", + package_list, + raise_for_status=raise_for_status, + ) - def get_scan_preferences(self, *, raise_for_status: bool = False) -> HttpResponse: + def get_scan_preferences( + self, *, raise_for_status: bool = False + ) -> HttpResponse: """ Gets the list of available scan preferences. @@ -111,7 +137,9 @@ def get_scan_preferences(self, *, raise_for_status: bool = False) -> HttpRespons Return: The HTTP response. See POST /scans/preferences in the openvasd API documentation. """ - return self._connector.get("/scans/preferences", raise_for_status=raise_for_status) + return self._connector.get( + "/scans/preferences", raise_for_status=raise_for_status + ) def create_scan( self, @@ -119,7 +147,7 @@ def create_scan( vt_selection: dict[str, Any], scanner_params: Optional[dict[str, Any]] = None, *, - raise_for_status: bool = False + raise_for_status: bool = False, ) -> HttpResponse: """ Creates a new scan without starting it. @@ -141,9 +169,13 @@ def create_scan( } if scanner_params: request_json["scan_preferences"] = scanner_params - return self._connector.post_json("/scans", request_json, raise_for_status=raise_for_status) + return self._connector.post_json( + "/scans", request_json, raise_for_status=raise_for_status + ) - def delete_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResponse: + def delete_scan( + self, scan_id: str, *, raise_for_status: bool = False + ) -> HttpResponse: """ Deletes a scan with the given id. @@ -155,7 +187,9 @@ def delete_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpRe The HTTP response. See DELETE /scans/{id} in the openvasd API documentation. """ quoted_scan_id = urllib.parse.quote(scan_id) - return self._connector.delete(f"/scans/{quoted_scan_id}", raise_for_status=raise_for_status) + return self._connector.delete( + f"/scans/{quoted_scan_id}", raise_for_status=raise_for_status + ) def get_scans(self, *, raise_for_status: bool = False) -> HttpResponse: """ @@ -169,7 +203,9 @@ def get_scans(self, *, raise_for_status: bool = False) -> HttpResponse: """ return self._connector.get("/scans", raise_for_status=raise_for_status) - def get_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResponse: + def get_scan( + self, scan_id: str, *, raise_for_status: bool = False + ) -> HttpResponse: """ Gets a scan with the given id. @@ -181,7 +217,9 @@ def get_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpRespo The HTTP response. See GET /scans/{id} in the openvasd API documentation. """ quoted_scan_id = urllib.parse.quote(scan_id) - return self._connector.get(f"/scans/{quoted_scan_id}", raise_for_status=raise_for_status) + return self._connector.get( + f"/scans/{quoted_scan_id}", raise_for_status=raise_for_status + ) def get_scan_results( self, @@ -189,7 +227,7 @@ def get_scan_results( range_start: Optional[int] = None, range_end: Optional[int] = None, *, - raise_for_status: bool = False + raise_for_status: bool = False, ) -> HttpResponse: """ Gets results of a scan with the given id. @@ -207,32 +245,41 @@ def get_scan_results( params = {} if range_start is not None: if not isinstance(range_start, int): - raise InvalidArgumentType(argument="range_start", function=self.get_scan_results.__name__) + raise InvalidArgumentType( + argument="range_start", + function=self.get_scan_results.__name__, + ) if range_end is not None: if not isinstance(range_end, int): - raise InvalidArgumentType(argument="range_end", function=self.get_scan_results.__name__) + raise InvalidArgumentType( + argument="range_end", + function=self.get_scan_results.__name__, + ) params["range"] = f"{range_start}-{range_end}" else: params["range"] = str(range_start) else: if range_end is not None: if not isinstance(range_end, int): - raise InvalidArgumentType(argument="range_end", function=self.get_scan_results.__name__) + raise InvalidArgumentType( + argument="range_end", + function=self.get_scan_results.__name__, + ) params["range"] = f"0-{range_end}" return self._connector.get( f"/scans/{quoted_scan_id}/results", params=params, - raise_for_status=raise_for_status + raise_for_status=raise_for_status, ) def get_scan_result( self, scan_id: str, - result_id: str|int, + result_id: str | int, *, - raise_for_status: bool = False + raise_for_status: bool = False, ) -> HttpResponse: """ Gets a single result of a scan with the given id. @@ -250,10 +297,12 @@ def get_scan_result( return self._connector.get( f"/scans/{quoted_scan_id}/results/{quoted_result_id}", - raise_for_status=raise_for_status + raise_for_status=raise_for_status, ) - def get_scan_status(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResponse: + def get_scan_status( + self, scan_id: str, *, raise_for_status: bool = False + ) -> HttpResponse: """ Gets a scan with the given id. @@ -265,9 +314,13 @@ def get_scan_status(self, scan_id: str, *, raise_for_status: bool = False) -> Ht The HTTP response. See GET /scans/{id}/{rid} in the openvasd API documentation. """ quoted_scan_id = urllib.parse.quote(scan_id) - return self._connector.get(f"/scans/{quoted_scan_id}/status", raise_for_status=raise_for_status) + return self._connector.get( + f"/scans/{quoted_scan_id}/status", raise_for_status=raise_for_status + ) - def run_scan_action(self, scan_id: str, scan_action: str, *, raise_for_status: bool = False) -> HttpResponse: + def run_scan_action( + self, scan_id: str, scan_action: str, *, raise_for_status: bool = False + ) -> HttpResponse: """ Performs an action like starting or stopping on a scan with the given id. @@ -280,10 +333,16 @@ def run_scan_action(self, scan_id: str, scan_action: str, *, raise_for_status: b The HTTP response. See POST /scans/{id} in the openvasd API documentation. """ quoted_scan_id = urllib.parse.quote(scan_id) - action_json = { "action": scan_action } - return self._connector.post_json(f"/scans/{quoted_scan_id}", action_json, raise_for_status=raise_for_status) + action_json = {"action": scan_action} + return self._connector.post_json( + f"/scans/{quoted_scan_id}", + action_json, + raise_for_status=raise_for_status, + ) - def start_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResponse: + def start_scan( + self, scan_id: str, *, raise_for_status: bool = False + ) -> HttpResponse: """ Starts a scan with the given id. @@ -294,9 +353,13 @@ def start_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpRes Return: The HTTP response. See POST /scans/{id} in the openvasd API documentation. """ - return self.run_scan_action(scan_id, "start", raise_for_status=raise_for_status) + return self.run_scan_action( + scan_id, "start", raise_for_status=raise_for_status + ) - def stop_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResponse: + def stop_scan( + self, scan_id: str, *, raise_for_status: bool = False + ) -> HttpResponse: """ Stops a scan with the given id. @@ -307,7 +370,9 @@ def stop_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResp Return: The HTTP response. See POST /scans/{id} in the openvasd API documentation. """ - return self.run_scan_action(scan_id, "stop", raise_for_status=raise_for_status) + return self.run_scan_action( + scan_id, "stop", raise_for_status=raise_for_status + ) def get_vts(self, *, raise_for_status: bool = False) -> HttpResponse: """ @@ -321,7 +386,9 @@ def get_vts(self, *, raise_for_status: bool = False) -> HttpResponse: """ return self._connector.get("/vts", raise_for_status=raise_for_status) - def get_vt(self, oid: str, *, raise_for_status: bool = False) -> HttpResponse: + def get_vt( + self, oid: str, *, raise_for_status: bool = False + ) -> HttpResponse: """ Gets the details of a vulnerability test (VT). @@ -333,4 +400,6 @@ def get_vt(self, oid: str, *, raise_for_status: bool = False) -> HttpResponse: The HTTP response. See DELETE /scans/{id} in the openvasd API documentation. """ quoted_oid = urllib.parse.quote(oid) - return self._connector.get(f"/vts/{quoted_oid}", raise_for_status=raise_for_status) + return self._connector.get( + f"/vts/{quoted_oid}", raise_for_status=raise_for_status + ) diff --git a/tests/http/core/test_api.py b/tests/http/core/test_api.py index 3cfaa2e6..a45dd7f2 100644 --- a/tests/http/core/test_api.py +++ b/tests/http/core/test_api.py @@ -11,12 +11,12 @@ class GvmHttpApiTestCase(unittest.TestCase): # pylint: disable=protected-access - @patch('gvm.http.core.connector.HttpApiConnector') + @patch("gvm.http.core.connector.HttpApiConnector") def test_basic_init(self, connector_mock: MagicMock): api = GvmHttpApi(connector_mock) self.assertEqual(connector_mock, api._connector) - @patch('gvm.http.core.connector.HttpApiConnector') + @patch("gvm.http.core.connector.HttpApiConnector") def test_init_with_key(self, connector_mock: MagicMock): api = GvmHttpApi(connector_mock, api_key="my-api-key") self.assertEqual(connector_mock, api._connector) diff --git a/tests/http/core/test_connector.py b/tests/http/core/test_connector.py index 83ba7adf..c2021652 100644 --- a/tests/http/core/test_connector.py +++ b/tests/http/core/test_connector.py @@ -16,7 +16,7 @@ TEST_JSON_HEADERS = { "content-type": "application/json;charset=utf-8", - "x-example": "some-test-header" + "x-example": "some-test-header", } TEST_JSON_CONTENT_TYPE = ContentType( @@ -37,11 +37,11 @@ def new_mock_empty_response( - status: Optional[int | HTTPStatus] = None, + status: Optional[int | HTTPStatus] = None, ) -> requests_lib.Response: # pylint: disable=protected-access response = requests_lib.Response() - response._content = b'' + response._content = b"" if status is None: response.status_code = int(HTTPStatus.NO_CONTENT) else: @@ -50,9 +50,9 @@ def new_mock_empty_response( def new_mock_json_response( - content: Optional[Any] = None, - status: Optional[int|HTTPStatus] = None, -)-> requests_lib.Response: + content: Optional[Any] = None, + status: Optional[int | HTTPStatus] = None, +) -> requests_lib.Response: # pylint: disable=protected-access response = requests_lib.Response() response._content = json.dumps(content).encode() @@ -66,10 +66,7 @@ def new_mock_json_response( return response -def new_mock_session( - *, - headers: Optional[dict] = None -) -> Mock: +def new_mock_session(*, headers: Optional[dict] = None) -> Mock: mock = Mock(spec=requests_lib.Session) mock.headers = headers if headers is not None else {} return mock @@ -81,38 +78,35 @@ class HttpApiConnectorTestCase(unittest.TestCase): def test_url_join(self): self.assertEqual( "http://localhost/foo/bar/baz", - HttpApiConnector.url_join("http://localhost/foo", "bar/baz") + HttpApiConnector.url_join("http://localhost/foo", "bar/baz"), ) self.assertEqual( "http://localhost/foo/bar/baz", - HttpApiConnector.url_join("http://localhost/foo/", "bar/baz") + HttpApiConnector.url_join("http://localhost/foo/", "bar/baz"), ) self.assertEqual( "http://localhost/foo/bar/baz", - HttpApiConnector.url_join("http://localhost/foo", "./bar/baz") + HttpApiConnector.url_join("http://localhost/foo", "./bar/baz"), ) self.assertEqual( "http://localhost/foo/bar/baz", - HttpApiConnector.url_join("http://localhost/foo/", "./bar/baz") + HttpApiConnector.url_join("http://localhost/foo/", "./bar/baz"), ) self.assertEqual( "http://localhost/bar/baz", - HttpApiConnector.url_join("http://localhost/foo", "../bar/baz") + HttpApiConnector.url_join("http://localhost/foo", "../bar/baz"), ) self.assertEqual( "http://localhost/bar/baz", - HttpApiConnector.url_join("http://localhost/foo", "../bar/baz") + HttpApiConnector.url_join("http://localhost/foo", "../bar/baz"), ) def test_new_session(self): new_session = HttpApiConnector._new_session() self.assertIsInstance(new_session, requests_lib.Session) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') - def test_basic_init( - self, - new_session_mock: MagicMock - ): + @patch("gvm.http.core.connector.HttpApiConnector._new_session") + def test_basic_init(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() connector = HttpApiConnector("http://localhost") @@ -120,14 +114,14 @@ def test_basic_init( self.assertEqual("http://localhost", connector.base_url) self.assertEqual(mock_session, connector._session) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_https_init(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() connector = HttpApiConnector( "https://localhost", server_ca_path="foo.crt", - client_cert_paths="bar.key" + client_cert_paths="bar.key", ) self.assertEqual("https://localhost", connector.base_url) @@ -135,7 +129,7 @@ def test_https_init(self, new_session_mock: MagicMock): self.assertEqual("foo.crt", mock_session.verify) self.assertEqual("bar.key", mock_session.cert) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_update_headers(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() @@ -147,13 +141,17 @@ def test_update_headers(self, new_session_mock: MagicMock): self.assertEqual({"x-foo": "bar", "x-baz": "123"}, mock_session.headers) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_delete(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() - mock_session.delete.return_value = new_mock_json_response(TEST_JSON_RESPONSE_BODY) + mock_session.delete.return_value = new_mock_json_response( + TEST_JSON_RESPONSE_BODY + ) connector = HttpApiConnector("https://localhost") - response = connector.delete("foo", params={"bar": "123"}, headers={"baz": "456"}) + response = connector.delete( + "foo", params={"bar": "123"}, headers={"baz": "456"} + ) self.assertEqual(int(HTTPStatus.OK), response.status) self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) @@ -161,12 +159,12 @@ def test_delete(self, new_session_mock: MagicMock): self.assertEqual(TEST_JSON_HEADERS, response.headers) mock_session.delete.assert_called_once_with( - 'https://localhost/foo', + "https://localhost/foo", params={"bar": "123"}, - headers={"baz": "456"} + headers={"baz": "456"}, ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_minimal_delete(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() @@ -180,26 +178,24 @@ def test_minimal_delete(self, new_session_mock: MagicMock): self.assertEqual({}, response.headers) mock_session.delete.assert_called_once_with( - 'https://localhost/foo', params=None, headers=None + "https://localhost/foo", params=None, headers=None ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_delete_raise_on_status(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() - mock_session.delete.return_value = new_mock_empty_response(HTTPStatus.INTERNAL_SERVER_ERROR) - connector = HttpApiConnector("https://localhost") - self.assertRaises( - HTTPError, - connector.delete, - "foo" + mock_session.delete.return_value = new_mock_empty_response( + HTTPStatus.INTERNAL_SERVER_ERROR ) + connector = HttpApiConnector("https://localhost") + self.assertRaises(HTTPError, connector.delete, "foo") mock_session.delete.assert_called_once_with( - 'https://localhost/foo', params=None, headers=None + "https://localhost/foo", params=None, headers=None ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_delete_no_raise_on_status(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() @@ -212,7 +208,7 @@ def test_delete_no_raise_on_status(self, new_session_mock: MagicMock): "foo", params={"bar": "123"}, headers={"baz": "456"}, - raise_for_status=False + raise_for_status=False, ) self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) @@ -221,18 +217,22 @@ def test_delete_no_raise_on_status(self, new_session_mock: MagicMock): self.assertEqual(TEST_JSON_HEADERS, response.headers) mock_session.delete.assert_called_once_with( - 'https://localhost/foo', + "https://localhost/foo", params={"bar": "123"}, - headers={"baz": "456"} + headers={"baz": "456"}, ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_get(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() - mock_session.get.return_value = new_mock_json_response(TEST_JSON_RESPONSE_BODY) + mock_session.get.return_value = new_mock_json_response( + TEST_JSON_RESPONSE_BODY + ) connector = HttpApiConnector("https://localhost") - response = connector.get("foo", params={"bar": "123"}, headers={"baz": "456"}) + response = connector.get( + "foo", params={"bar": "123"}, headers={"baz": "456"} + ) self.assertEqual(int(HTTPStatus.OK), response.status) self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) @@ -240,12 +240,12 @@ def test_get(self, new_session_mock: MagicMock): self.assertEqual(TEST_JSON_HEADERS, response.headers) mock_session.get.assert_called_once_with( - 'https://localhost/foo', + "https://localhost/foo", params={"bar": "123"}, - headers={"baz": "456"} + headers={"baz": "456"}, ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_minimal_get(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() @@ -259,26 +259,24 @@ def test_minimal_get(self, new_session_mock: MagicMock): self.assertEqual({}, response.headers) mock_session.get.assert_called_once_with( - 'https://localhost/foo', params=None, headers=None + "https://localhost/foo", params=None, headers=None ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_get_raise_on_status(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() - mock_session.get.return_value = new_mock_empty_response(HTTPStatus.INTERNAL_SERVER_ERROR) - connector = HttpApiConnector("https://localhost") - self.assertRaises( - HTTPError, - connector.get, - "foo" + mock_session.get.return_value = new_mock_empty_response( + HTTPStatus.INTERNAL_SERVER_ERROR ) + connector = HttpApiConnector("https://localhost") + self.assertRaises(HTTPError, connector.get, "foo") mock_session.get.assert_called_once_with( - 'https://localhost/foo', params=None, headers=None + "https://localhost/foo", params=None, headers=None ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_get_no_raise_on_status(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() @@ -291,7 +289,7 @@ def test_get_no_raise_on_status(self, new_session_mock: MagicMock): "foo", params={"bar": "123"}, headers={"baz": "456"}, - raise_for_status=False + raise_for_status=False, ) self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) @@ -300,22 +298,24 @@ def test_get_no_raise_on_status(self, new_session_mock: MagicMock): self.assertEqual(TEST_JSON_HEADERS, response.headers) mock_session.get.assert_called_once_with( - 'https://localhost/foo', + "https://localhost/foo", params={"bar": "123"}, - headers={"baz": "456"} + headers={"baz": "456"}, ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_post_json(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() - mock_session.post.return_value = new_mock_json_response(TEST_JSON_RESPONSE_BODY) + mock_session.post.return_value = new_mock_json_response( + TEST_JSON_RESPONSE_BODY + ) connector = HttpApiConnector("https://localhost") response = connector.post_json( "foo", json={"number": 5}, params={"bar": "123"}, - headers={"baz": "456"} + headers={"baz": "456"}, ) self.assertEqual(int(HTTPStatus.OK), response.status) @@ -324,13 +324,13 @@ def test_post_json(self, new_session_mock: MagicMock): self.assertEqual(TEST_JSON_HEADERS, response.headers) mock_session.post.assert_called_once_with( - 'https://localhost/foo', + "https://localhost/foo", json={"number": 5}, params={"bar": "123"}, - headers={"baz": "456"} + headers={"baz": "456"}, ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_minimal_post_json(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() @@ -344,27 +344,32 @@ def test_minimal_post_json(self, new_session_mock: MagicMock): self.assertEqual({}, response.headers) mock_session.post.assert_called_once_with( - 'https://localhost/foo', json=TEST_JSON_REQUEST_BODY, params=None, headers=None + "https://localhost/foo", + json=TEST_JSON_REQUEST_BODY, + params=None, + headers=None, ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_post_json_raise_on_status(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() - mock_session.post.return_value = new_mock_empty_response(HTTPStatus.INTERNAL_SERVER_ERROR) + mock_session.post.return_value = new_mock_empty_response( + HTTPStatus.INTERNAL_SERVER_ERROR + ) connector = HttpApiConnector("https://localhost") self.assertRaises( - HTTPError, - connector.post_json, - "foo", - json=TEST_JSON_REQUEST_BODY + HTTPError, connector.post_json, "foo", json=TEST_JSON_REQUEST_BODY ) mock_session.post.assert_called_once_with( - 'https://localhost/foo', json=TEST_JSON_REQUEST_BODY, params=None, headers=None + "https://localhost/foo", + json=TEST_JSON_REQUEST_BODY, + params=None, + headers=None, ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_post_json_no_raise_on_status(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() @@ -378,7 +383,7 @@ def test_post_json_no_raise_on_status(self, new_session_mock: MagicMock): json=TEST_JSON_REQUEST_BODY, params={"bar": "123"}, headers={"baz": "456"}, - raise_for_status=False + raise_for_status=False, ) self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) @@ -387,8 +392,8 @@ def test_post_json_no_raise_on_status(self, new_session_mock: MagicMock): self.assertEqual(TEST_JSON_HEADERS, response.headers) mock_session.post.assert_called_once_with( - 'https://localhost/foo', + "https://localhost/foo", json=TEST_JSON_REQUEST_BODY, params={"bar": "123"}, - headers={"baz": "456"} - ) \ No newline at end of file + headers={"baz": "456"}, + ) diff --git a/tests/http/core/test_headers.py b/tests/http/core/test_headers.py index 032a4301..07257d73 100644 --- a/tests/http/core/test_headers.py +++ b/tests/http/core/test_headers.py @@ -28,9 +28,13 @@ def test_from_string_with_charset(self): self.assertEqual("utf-32", ct.charset) def test_from_string_with_param(self): - ct = ContentType.from_string("multipart/form-data; boundary===boundary==; charset=utf-32 ") + ct = ContentType.from_string( + "multipart/form-data; boundary===boundary==; charset=utf-32 " + ) self.assertEqual("multipart/form-data", ct.media_type) - self.assertEqual({"boundary": "==boundary==", "charset": "utf-32"}, ct.params) + self.assertEqual( + {"boundary": "==boundary==", "charset": "utf-32"}, ct.params + ) self.assertEqual("utf-32", ct.charset) def test_from_string_with_valueless_param(self): diff --git a/tests/http/core/test_response.py b/tests/http/core/test_response.py index 8edf0dda..98657a19 100644 --- a/tests/http/core/test_response.py +++ b/tests/http/core/test_response.py @@ -9,11 +9,12 @@ from gvm.http.core.response import HttpResponse + class HttpResponseFromRequestsLibTestCase(unittest.TestCase): def test_from_empty_response(self): requests_response = requests_lib.Response() requests_response.status_code = int(HTTPStatus.OK) - requests_response._content = b'' + requests_response._content = b"" response = HttpResponse.from_requests_lib(requests_response) @@ -25,11 +26,11 @@ def test_from_plain_text_response(self): requests_response = requests_lib.Response() requests_response.status_code = int(HTTPStatus.OK) requests_response.headers.update({"content-type": "text/plain"}) - requests_response._content = b'ABCDEF' + requests_response._content = b"ABCDEF" response = HttpResponse.from_requests_lib(requests_response) - self.assertEqual(b'ABCDEF', response.body) + self.assertEqual(b"ABCDEF", response.body) self.assertEqual(int(HTTPStatus.OK), response.status) self.assertEqual({"content-type": "text/plain"}, response.headers) diff --git a/tests/http/openvasd/test_openvasd1.py b/tests/http/openvasd/test_openvasd1.py index a791f8b4..09b3a5c8 100644 --- a/tests/http/openvasd/test_openvasd1.py +++ b/tests/http/openvasd/test_openvasd1.py @@ -15,15 +15,17 @@ def new_mock_empty_response( - status: Optional[int | HTTPStatus] = None, - headers: Optional[dict[str, str]] = None, + status: Optional[int | HTTPStatus] = None, + headers: Optional[dict[str, str]] = None, ): if status is None: status = int(HTTPStatus.NO_CONTENT) if headers is None: headers = [] content_type = ContentType.from_string(None) - return HttpResponse(body=None, status=status, headers=headers, content_type=content_type) + return HttpResponse( + body=None, status=status, headers=headers, content_type=content_type + ) class OpenvasdHttpApiV1TestCase(unittest.TestCase): @@ -37,7 +39,9 @@ def test_init(self, mock_connector: Mock): @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) def test_init_with_api_key(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector, api_key="my-API-key") - mock_connector.update_headers.assert_called_once_with({"X-API-KEY": "my-API-key"}) + mock_connector.update_headers.assert_called_once_with( + {"X-API-KEY": "my-API-key"} + ) self.assertIsNotNone(api) @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) @@ -47,7 +51,9 @@ def test_get_health_alive(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector) response = api.get_health_alive() - mock_connector.get.assert_called_once_with("/health/alive", raise_for_status=False) + mock_connector.get.assert_called_once_with( + "/health/alive", raise_for_status=False + ) self.assertEqual(expected_response, response) @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) @@ -57,7 +63,9 @@ def test_get_health_ready(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector) response = api.get_health_ready() - mock_connector.get.assert_called_once_with("/health/ready", raise_for_status=False) + mock_connector.get.assert_called_once_with( + "/health/ready", raise_for_status=False + ) self.assertEqual(expected_response, response) @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) @@ -67,7 +75,9 @@ def test_get_health_started(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector) response = api.get_health_started() - mock_connector.get.assert_called_once_with("/health/started", raise_for_status=False) + mock_connector.get.assert_called_once_with( + "/health/started", raise_for_status=False + ) self.assertEqual(expected_response, response) @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) @@ -77,7 +87,9 @@ def test_get_notus_os_list(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector) response = api.get_notus_os_list() - mock_connector.get.assert_called_once_with("/notus", raise_for_status=False) + mock_connector.get.assert_called_once_with( + "/notus", raise_for_status=False + ) self.assertEqual(expected_response, response) @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) @@ -90,7 +102,7 @@ def test_run_notus_scan(self, mock_connector: Mock): mock_connector.post_json.assert_called_once_with( "/notus/Debian%2011", ["foo-1.0", "bar-0.23"], - raise_for_status=False + raise_for_status=False, ) self.assertEqual(expected_response, response) @@ -101,7 +113,9 @@ def test_get_scan_preferences(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector) response = api.get_scan_preferences() - mock_connector.get.assert_called_once_with("/scans/preferences", raise_for_status=False) + mock_connector.get.assert_called_once_with( + "/scans/preferences", raise_for_status=False + ) self.assertEqual(expected_response, response) @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) @@ -121,7 +135,7 @@ def test_create_scan(self, mock_connector: Mock): "target": {"hosts": "somehost"}, "vts": ["some_vt", "another_vt"], }, - raise_for_status=False + raise_for_status=False, ) # scan with all options @@ -136,9 +150,9 @@ def test_create_scan(self, mock_connector: Mock): { "target": {"hosts": "somehost"}, "vts": ["some_vt", "another_vt"], - "scan_preferences": {"my_scanner_param": "abc"} + "scan_preferences": {"my_scanner_param": "abc"}, }, - raise_for_status=False + raise_for_status=False, ) self.assertEqual(expected_response, response) self.assertEqual(expected_response, response) @@ -150,8 +164,11 @@ def test_delete_scan(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector) response = api.delete_scan("foo bar") - mock_connector.delete.assert_called_once_with("/scans/foo%20bar", raise_for_status=False) + mock_connector.delete.assert_called_once_with( + "/scans/foo%20bar", raise_for_status=False + ) self.assertEqual(expected_response, response) + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) def test_get_scans(self, mock_connector: Mock): expected_response = new_mock_empty_response() @@ -159,7 +176,9 @@ def test_get_scans(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector) response = api.get_scans() - mock_connector.get.assert_called_once_with("/scans", raise_for_status=False) + mock_connector.get.assert_called_once_with( + "/scans", raise_for_status=False + ) self.assertEqual(expected_response, response) @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) @@ -169,10 +188,11 @@ def test_get_scan(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector) response = api.get_scan("foo bar") - mock_connector.get.assert_called_once_with("/scans/foo%20bar", raise_for_status=False) + mock_connector.get.assert_called_once_with( + "/scans/foo%20bar", raise_for_status=False + ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) def test_get_scan_results(self, mock_connector: Mock): expected_response = new_mock_empty_response() @@ -181,9 +201,7 @@ def test_get_scan_results(self, mock_connector: Mock): response = api.get_scan_results("foo bar") mock_connector.get.assert_called_once_with( - "/scans/foo%20bar/results", - params={}, - raise_for_status=False + "/scans/foo%20bar/results", params={}, raise_for_status=False ) self.assertEqual(expected_response, response) @@ -198,7 +216,7 @@ def test_get_scan_results_with_ranges(self, mock_connector: Mock): mock_connector.get.assert_called_once_with( "/scans/foo%20bar/results", params={"range": "12"}, - raise_for_status=False + raise_for_status=False, ) self.assertEqual(expected_response, response) @@ -208,7 +226,7 @@ def test_get_scan_results_with_ranges(self, mock_connector: Mock): mock_connector.get.assert_called_once_with( "/scans/foo%20bar/results", params={"range": "12-34"}, - raise_for_status=False + raise_for_status=False, ) self.assertEqual(expected_response, response) @@ -218,7 +236,7 @@ def test_get_scan_results_with_ranges(self, mock_connector: Mock): mock_connector.get.assert_called_once_with( "/scans/foo%20bar/results", params={"range": "0-23"}, - raise_for_status=False + raise_for_status=False, ) self.assertEqual(expected_response, response) @@ -230,23 +248,20 @@ def test_get_scan_results_with_invalid_ranges(self, mock_connector: Mock): # range start self.assertRaises( - InvalidArgumentType, - api.get_scan_results, - "foo bar", "invalid" + InvalidArgumentType, api.get_scan_results, "foo bar", "invalid" ) # range start and end self.assertRaises( - InvalidArgumentType, - api.get_scan_results, - "foo bar", 12, "invalid" + InvalidArgumentType, api.get_scan_results, "foo bar", 12, "invalid" ) # range end only self.assertRaises( InvalidArgumentType, api.get_scan_results, - "foo bar", range_end="invalid" + "foo bar", + range_end="invalid", ) @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) @@ -257,8 +272,7 @@ def test_get_scan_result(self, mock_connector: Mock): response = api.get_scan_result("foo bar", "baz qux") mock_connector.get.assert_called_once_with( - "/scans/foo%20bar/results/baz%20qux", - raise_for_status=False + "/scans/foo%20bar/results/baz%20qux", raise_for_status=False ) self.assertEqual(expected_response, response) @@ -270,8 +284,7 @@ def test_get_scan_status(self, mock_connector: Mock): response = api.get_scan_status("foo bar") mock_connector.get.assert_called_once_with( - "/scans/foo%20bar/status", - raise_for_status=False + "/scans/foo%20bar/status", raise_for_status=False ) self.assertEqual(expected_response, response) @@ -285,7 +298,7 @@ def test_run_scan_action(self, mock_connector: Mock): mock_connector.post_json.assert_called_once_with( "/scans/foo%20bar", {"action": "do-something"}, - raise_for_status=False + raise_for_status=False, ) self.assertEqual(expected_response, response) @@ -297,9 +310,7 @@ def test_start_scan(self, mock_connector: Mock): response = api.start_scan("foo bar") mock_connector.post_json.assert_called_once_with( - "/scans/foo%20bar", - {"action": "start"}, - raise_for_status=False + "/scans/foo%20bar", {"action": "start"}, raise_for_status=False ) self.assertEqual(expected_response, response) @@ -311,9 +322,7 @@ def test_stop_scan(self, mock_connector: Mock): response = api.stop_scan("foo bar") mock_connector.post_json.assert_called_once_with( - "/scans/foo%20bar", - {"action": "stop"}, - raise_for_status=False + "/scans/foo%20bar", {"action": "stop"}, raise_for_status=False ) self.assertEqual(expected_response, response) @@ -325,8 +334,7 @@ def test_get_vts(self, mock_connector: Mock): response = api.get_vts() mock_connector.get.assert_called_once_with( - "/vts", - raise_for_status=False + "/vts", raise_for_status=False ) self.assertEqual(expected_response, response) @@ -338,7 +346,6 @@ def test_get_vt(self, mock_connector: Mock): response = api.get_vt("foo bar") mock_connector.get.assert_called_once_with( - "/vts/foo%20bar", - raise_for_status=False + "/vts/foo%20bar", raise_for_status=False ) self.assertEqual(expected_response, response) From dc76dae1b55264aa81f8202855f459746d536bf2 Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 08:53:23 +0200 Subject: [PATCH 04/34] Address linter warnings --- gvm/http/core/_api.py | 5 +++-- gvm/http/core/connector.py | 6 +++--- gvm/http/core/headers.py | 6 +++--- gvm/http/core/response.py | 5 +++-- gvm/http/openvasd/openvasd1.py | 3 +-- tests/http/core/test_api.py | 2 +- tests/http/core/test_connector.py | 9 ++++----- tests/http/openvasd/test_openvasd1.py | 1 - 8 files changed, 18 insertions(+), 19 deletions(-) diff --git a/gvm/http/core/_api.py b/gvm/http/core/_api.py index bd7a5c1f..e8418d0f 100644 --- a/gvm/http/core/_api.py +++ b/gvm/http/core/_api.py @@ -27,8 +27,9 @@ def __init__( api_key: Optional API key for authentication """ - "The connector handling the HTTP(S) connection" + self._connector: HttpApiConnector = connector + "The connector handling the HTTP(S) connection" - "Optional API key for authentication" self._api_key: Optional[str] = api_key + "Optional API key for authentication" diff --git a/gvm/http/core/connector.py b/gvm/http/core/connector.py index e683edf7..f46135e0 100644 --- a/gvm/http/core/connector.py +++ b/gvm/http/core/connector.py @@ -7,7 +7,7 @@ """ import urllib.parse -from typing import Optional, Tuple, Dict, Any +from typing import Any, Dict, Optional, Tuple from requests import Session @@ -36,8 +36,8 @@ def url_join(cls, base: str, rel_path: str) -> str: """ if base.endswith("/"): return urllib.parse.urljoin(base, rel_path) - else: - return urllib.parse.urljoin(base + "/", rel_path) + + return urllib.parse.urljoin(base + "/", rel_path) def __init__( self, diff --git a/gvm/http/core/headers.py b/gvm/http/core/headers.py index 6d384049..4c45ef41 100644 --- a/gvm/http/core/headers.py +++ b/gvm/http/core/headers.py @@ -7,7 +7,7 @@ """ from dataclasses import dataclass -from typing import Self, Dict, Optional +from typing import Dict, Optional, Self @dataclass @@ -45,8 +45,8 @@ def from_string( if header_string: parts = header_string.split(";") media_type = parts[0].strip() - for param in parts[1:]: - param = param.strip() + for part in parts[1:]: + param = part.strip() if "=" in param: key, value = map(lambda x: x.strip(), param.split("=", 1)) params[key] = value diff --git a/gvm/http/core/response.py b/gvm/http/core/response.py index 30b3fb97..37c3c37e 100644 --- a/gvm/http/core/response.py +++ b/gvm/http/core/response.py @@ -7,8 +7,9 @@ """ from dataclasses import dataclass -from typing import Any, Dict, Self, Optional -from requests import Request, Response +from typing import Any, Dict, Optional, Self + +from requests import Response from gvm.http.core.headers import ContentType diff --git a/gvm/http/openvasd/openvasd1.py b/gvm/http/openvasd/openvasd1.py index 86526a72..a15e9808 100644 --- a/gvm/http/openvasd/openvasd1.py +++ b/gvm/http/openvasd/openvasd1.py @@ -7,10 +7,9 @@ """ import urllib.parse -from typing import Optional, Any +from typing import Any, Optional from gvm.errors import InvalidArgumentType - from gvm.http.core._api import GvmHttpApi from gvm.http.core.connector import HttpApiConnector from gvm.http.core.response import HttpResponse diff --git a/tests/http/core/test_api.py b/tests/http/core/test_api.py index a45dd7f2..fe3876b1 100644 --- a/tests/http/core/test_api.py +++ b/tests/http/core/test_api.py @@ -3,7 +3,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later import unittest -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch from gvm.http.core._api import GvmHttpApi diff --git a/tests/http/core/test_connector.py b/tests/http/core/test_connector.py index c2021652..22cfb2e0 100644 --- a/tests/http/core/test_connector.py +++ b/tests/http/core/test_connector.py @@ -4,16 +4,15 @@ import json import unittest from http import HTTPStatus -from typing import Optional, Any -from unittest.mock import patch, MagicMock, Mock -from requests.exceptions import HTTPError +from typing import Any, Optional +from unittest.mock import MagicMock, Mock, patch -from gvm.http.core.connector import HttpApiConnector import requests as requests_lib +from requests.exceptions import HTTPError +from gvm.http.core.connector import HttpApiConnector from gvm.http.core.headers import ContentType - TEST_JSON_HEADERS = { "content-type": "application/json;charset=utf-8", "x-example": "some-test-header", diff --git a/tests/http/openvasd/test_openvasd1.py b/tests/http/openvasd/test_openvasd1.py index 09b3a5c8..91c11b71 100644 --- a/tests/http/openvasd/test_openvasd1.py +++ b/tests/http/openvasd/test_openvasd1.py @@ -8,7 +8,6 @@ from unittest.mock import Mock, patch from gvm.errors import InvalidArgumentType - from gvm.http.core.headers import ContentType from gvm.http.core.response import HttpResponse from gvm.http.openvasd.openvasd1 import OpenvasdHttpApiV1 From 8484c80d0a184a48ccdac34c316b4f9281bc1058 Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 09:24:32 +0200 Subject: [PATCH 05/34] Break some overlong docstring lines --- gvm/http/core/connector.py | 26 ++++++++++------ gvm/http/openvasd/openvasd1.py | 57 ++++++++++++++++++++++------------ 2 files changed, 54 insertions(+), 29 deletions(-) diff --git a/gvm/http/core/connector.py b/gvm/http/core/connector.py index f46135e0..ff6bedf7 100644 --- a/gvm/http/core/connector.py +++ b/gvm/http/core/connector.py @@ -31,8 +31,8 @@ def url_join(cls, base: str, rel_path: str) -> str: """ Combines a base URL and a relative path into one URL. - Unlike `urrlib.parse.urljoin` the base path will always be the parent of the relative path as if it - ends with "/". + Unlike `urrlib.parse.urljoin` the base path will always be the parent of the + relative path as if it ends with "/". """ if base.endswith("/"): return urllib.parse.urljoin(base, rel_path) @@ -50,12 +50,14 @@ def __init__( Create a new HTTP API Connector. Args: - base_url: The base server URL to which request-specific paths will be appended for the requests + base_url: The base server URL to which request-specific paths will be appended + for the requests server_ca_path: Optional path to a CA certificate for verifying the server. If none is given, server verification is disabled. - client_cert_paths: Optional path to a client private key and certificate for authentication. - Can be a combined key and certificate file or a tuple containing separate files. - The key must not be encrypted. + client_cert_paths: Optional path to a client private key and certificate + for authentication. + Can be a combined key and certificate file or a tuple containing separate files. + The key must not be encrypted. """ self.base_url = base_url @@ -90,7 +92,8 @@ def delete( Args: rel_path: The relative path for the request - raise_for_status: Whether to raise an error if response has a non-success HTTP status code + raise_for_status: Whether to raise an error if response has a + non-success HTTP status code params: Optional dict of URL-encoded parameters headers: Optional additional headers added to the request @@ -116,7 +119,8 @@ def get( Args: rel_path: The relative path for the request - raise_for_status: Whether to raise an error if response has a non-success HTTP status code + raise_for_status: Whether to raise an error if response has a + non-success HTTP status code params: Optional dict of URL-encoded parameters headers: Optional additional headers added to the request @@ -139,12 +143,14 @@ def post_json( headers: Optional[Dict[str, str]] = None, ) -> HttpResponse: """ - Sends a ``POST`` request, using the given JSON-compatible object as the request body, and returns the response. + Sends a ``POST`` request, using the given JSON-compatible object as the + request body, and returns the response. Args: rel_path: The relative path for the request json: The object to use as the request body. - raise_for_status: Whether to raise an error if response has a non-success HTTP status code + raise_for_status: Whether to raise an error if response has a + non-success HTTP status code params: Optional dict of URL-encoded parameters headers: Optional additional headers added to the request diff --git a/gvm/http/openvasd/openvasd1.py b/gvm/http/openvasd/openvasd1.py index a15e9808..730fcb21 100644 --- a/gvm/http/openvasd/openvasd1.py +++ b/gvm/http/openvasd/openvasd1.py @@ -44,7 +44,8 @@ def get_health_alive( Gets the "alive" health status of the scanner. Args: - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See GET /health/alive in the openvasd API documentation. @@ -60,7 +61,8 @@ def get_health_ready( Gets the "ready" health status of the scanner. Args: - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See GET /health/ready in the openvasd API documentation. @@ -76,7 +78,8 @@ def get_health_started( Gets the "started" health status of the scanner. Args: - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See GET /health/started in the openvasd API documentation. @@ -92,7 +95,8 @@ def get_notus_os_list( Gets the list of operating systems available in Notus. Args: - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See GET /notus in the openvasd API documentation. @@ -110,9 +114,11 @@ def run_notus_scan( Gets the Notus results for a given operating system and list of packages. Args: - os: Name of the operating system as returned in the list returned by get_notus_os_products. + os: Name of the operating system as returned in the list returned by + get_notus_os_products. package_list: List of package names to check. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See POST /notus/{os} in the openvasd API documentation. @@ -131,7 +137,8 @@ def get_scan_preferences( Gets the list of available scan preferences. Args: - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See POST /scans/preferences in the openvasd API documentation. @@ -157,7 +164,8 @@ def create_scan( target: The target definition for the scan. vt_selection: The VT selection for the scan, including VT preferences. scanner_params: The optional scanner parameters. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See POST /scans in the openvasd API documentation. @@ -180,7 +188,8 @@ def delete_scan( Args: scan_id: The id of the scan to perform the action on. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See DELETE /scans/{id} in the openvasd API documentation. @@ -195,7 +204,8 @@ def get_scans(self, *, raise_for_status: bool = False) -> HttpResponse: Gets the list of available scans. Args: - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See GET /scans in the openvasd API documentation. @@ -210,7 +220,8 @@ def get_scan( Args: scan_id: The id of the scan to get. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See GET /scans/{id} in the openvasd API documentation. @@ -235,7 +246,8 @@ def get_scan_results( scan_id: The id of the scan to get the results of. range_start: Optional index of the first result to get. range_end: Optional index of the last result to get. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See GET /scans/{id}/results in the openvasd API documentation. @@ -286,7 +298,8 @@ def get_scan_result( Args: scan_id: The id of the scan to get the results of. result_id: The id of the result to get. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See GET /scans/{id}/{rid} in the openvasd API documentation. @@ -307,7 +320,8 @@ def get_scan_status( Args: scan_id: The id of the scan to get the status of. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See GET /scans/{id}/{rid} in the openvasd API documentation. @@ -326,7 +340,8 @@ def run_scan_action( Args: scan_id: The id of the scan to perform the action on. scan_action: The action to perform. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See POST /scans/{id} in the openvasd API documentation. @@ -347,7 +362,8 @@ def start_scan( Args: scan_id: The id of the scan to perform the action on. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See POST /scans/{id} in the openvasd API documentation. @@ -364,7 +380,8 @@ def stop_scan( Args: scan_id: The id of the scan to perform the action on. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See POST /scans/{id} in the openvasd API documentation. @@ -378,7 +395,8 @@ def get_vts(self, *, raise_for_status: bool = False) -> HttpResponse: Gets a list of available vulnerability tests (VTs) on the scanner. Args: - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See GET /vts in the openvasd API documentation. @@ -393,7 +411,8 @@ def get_vt( Args: oid: OID of the VT to get. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See DELETE /scans/{id} in the openvasd API documentation. From bdf2eb130f48720ce3b5ce0b207611a56b57356d Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 09:26:47 +0200 Subject: [PATCH 06/34] Address linter warnings in HTTP tests --- tests/http/core/test_response.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/http/core/test_response.py b/tests/http/core/test_response.py index 98657a19..5dbbcf67 100644 --- a/tests/http/core/test_response.py +++ b/tests/http/core/test_response.py @@ -12,6 +12,7 @@ class HttpResponseFromRequestsLibTestCase(unittest.TestCase): def test_from_empty_response(self): + # pylint: disable=protected-access requests_response = requests_lib.Response() requests_response.status_code = int(HTTPStatus.OK) requests_response._content = b"" @@ -23,6 +24,7 @@ def test_from_empty_response(self): self.assertEqual({}, response.headers) def test_from_plain_text_response(self): + # pylint: disable=protected-access requests_response = requests_lib.Response() requests_response.status_code = int(HTTPStatus.OK) requests_response.headers.update({"content-type": "text/plain"}) @@ -35,6 +37,7 @@ def test_from_plain_text_response(self): self.assertEqual({"content-type": "text/plain"}, response.headers) def test_from_json_response(self): + # pylint: disable=protected-access test_content = {"foo": ["bar", 12345], "baz": True} requests_response = requests_lib.Response() requests_response.status_code = int(HTTPStatus.OK) @@ -48,6 +51,7 @@ def test_from_json_response(self): self.assertEqual({"content-type": "application/json"}, response.headers) def test_from_error_json_response(self): + # pylint: disable=protected-access test_content = {"error": "Internal server error"} requests_response = requests_lib.Response() requests_response.status_code = int(HTTPStatus.INTERNAL_SERVER_ERROR) From bb8d1758d8d2e3e179fc3c97d07e8cdb7e2d81b5 Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 09:56:01 +0200 Subject: [PATCH 07/34] Use workaround for typing.Self missing in older Python versions --- gvm/http/core/headers.py | 5 ++++- gvm/http/core/response.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/gvm/http/core/headers.py b/gvm/http/core/headers.py index 4c45ef41..21e9b89b 100644 --- a/gvm/http/core/headers.py +++ b/gvm/http/core/headers.py @@ -7,7 +7,10 @@ """ from dataclasses import dataclass -from typing import Dict, Optional, Self +from typing import Dict, Optional, TypeVar + + +Self = TypeVar("Self", bound="ContentType") @dataclass diff --git a/gvm/http/core/response.py b/gvm/http/core/response.py index 37c3c37e..0e9af4ae 100644 --- a/gvm/http/core/response.py +++ b/gvm/http/core/response.py @@ -7,13 +7,16 @@ """ from dataclasses import dataclass -from typing import Any, Dict, Optional, Self +from typing import Any, Dict, Optional, TypeVar from requests import Response from gvm.http.core.headers import ContentType +Self = TypeVar("Self", bound="HttpResponse") + + @dataclass class HttpResponse: """ From ae985bd40cf25c39455e45af7922c5b40490596c Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 09:58:57 +0200 Subject: [PATCH 08/34] Move misplaced assertion in test_create_scan to correct place --- tests/http/openvasd/test_openvasd1.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/http/openvasd/test_openvasd1.py b/tests/http/openvasd/test_openvasd1.py index 91c11b71..c8edc837 100644 --- a/tests/http/openvasd/test_openvasd1.py +++ b/tests/http/openvasd/test_openvasd1.py @@ -136,6 +136,7 @@ def test_create_scan(self, mock_connector: Mock): }, raise_for_status=False, ) + self.assertEqual(expected_response, response) # scan with all options mock_connector.post_json.reset_mock() @@ -154,7 +155,6 @@ def test_create_scan(self, mock_connector: Mock): raise_for_status=False, ) self.assertEqual(expected_response, response) - self.assertEqual(expected_response, response) @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) def test_delete_scan(self, mock_connector: Mock): From b05f62b92b07fdc21282342810db15951313e3d1 Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 10:36:45 +0200 Subject: [PATCH 09/34] Clean up type hints for HTTP APIs --- gvm/http/core/connector.py | 4 ++-- gvm/http/core/headers.py | 18 +++++++++--------- gvm/http/core/response.py | 7 ++++--- gvm/http/openvasd/openvasd1.py | 6 +++--- poetry.lock | 15 +++++++++++++++ pyproject.toml | 1 + tests/http/openvasd/test_openvasd1.py | 6 +++--- 7 files changed, 37 insertions(+), 20 deletions(-) diff --git a/gvm/http/core/connector.py b/gvm/http/core/connector.py index ff6bedf7..7687a3b9 100644 --- a/gvm/http/core/connector.py +++ b/gvm/http/core/connector.py @@ -7,7 +7,7 @@ """ import urllib.parse -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, Optional, Tuple, Union from requests import Session @@ -44,7 +44,7 @@ def __init__( base_url: str, *, server_ca_path: Optional[str] = None, - client_cert_paths: Optional[str | Tuple[str]] = None, + client_cert_paths: Optional[Union[str, Tuple[str]]] = None, ): """ Create a new HTTP API Connector. diff --git a/gvm/http/core/headers.py b/gvm/http/core/headers.py index 21e9b89b..3ecec720 100644 --- a/gvm/http/core/headers.py +++ b/gvm/http/core/headers.py @@ -7,8 +7,7 @@ """ from dataclasses import dataclass -from typing import Dict, Optional, TypeVar - +from typing import Dict, Optional, TypeVar, Type, Union Self = TypeVar("Self", bound="ContentType") @@ -22,7 +21,7 @@ class ContentType: media_type: str 'The MIME media type, e.g. "application/json"' - params: Dict[str, str] + params: Dict[str, Union[bool, str]] "Dictionary of parameters in the content type header" charset: Optional[str] @@ -30,10 +29,10 @@ class ContentType: @classmethod def from_string( - cls, - header_string: str, - fallback_media_type: Optional[str] = "application/octet-stream", - ) -> Self: + cls: Type[Self], + header_string: Optional[str], + fallback_media_type: str = "application/octet-stream", + ) -> "ContentType": """ Parse the content of content type header into a ContentType object. @@ -42,12 +41,13 @@ def from_string( fallback_media_type: The media type to use if the `header_string` is `None` or empty. """ media_type = fallback_media_type - params = {} + params: Dict[str, Union[bool, str]] = {} charset = None if header_string: parts = header_string.split(";") - media_type = parts[0].strip() + if len(parts) > 0: + media_type = parts[0].strip() for part in parts[1:]: param = part.strip() if "=" in param: diff --git a/gvm/http/core/response.py b/gvm/http/core/response.py index 0e9af4ae..2436bd1c 100644 --- a/gvm/http/core/response.py +++ b/gvm/http/core/response.py @@ -7,9 +7,10 @@ """ from dataclasses import dataclass -from typing import Any, Dict, Optional, TypeVar +from typing import Any, Dict, Optional, TypeVar, Type, Union from requests import Response +from requests.structures import CaseInsensitiveDict from gvm.http.core.headers import ContentType @@ -29,14 +30,14 @@ class HttpResponse: status: int "HTTP status code of the response" - headers: Dict[str, str] + headers: Union[Dict[str, str], CaseInsensitiveDict[str]] "Dict containing the headers of the response" content_type: Optional[ContentType] "The content type of the response if it was included in the headers" @classmethod - def from_requests_lib(cls, r: Response) -> Self: + def from_requests_lib(cls: Type[Self], r: Response) -> "HttpResponse": """ Creates a new HTTP response object from a Request object created by the "Requests" library. diff --git a/gvm/http/openvasd/openvasd1.py b/gvm/http/openvasd/openvasd1.py index 730fcb21..1255870c 100644 --- a/gvm/http/openvasd/openvasd1.py +++ b/gvm/http/openvasd/openvasd1.py @@ -7,7 +7,7 @@ """ import urllib.parse -from typing import Any, Optional +from typing import Any, Optional, Union from gvm.errors import InvalidArgumentType from gvm.http.core._api import GvmHttpApi @@ -150,7 +150,7 @@ def get_scan_preferences( def create_scan( self, target: dict[str, Any], - vt_selection: dict[str, Any], + vt_selection: list[dict[str, Any]], scanner_params: Optional[dict[str, Any]] = None, *, raise_for_status: bool = False, @@ -288,7 +288,7 @@ def get_scan_results( def get_scan_result( self, scan_id: str, - result_id: str | int, + result_id: Union[str, int], *, raise_for_status: bool = False, ) -> HttpResponse: diff --git a/poetry.lock b/poetry.lock index 05f5c7a7..e520b441 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1807,6 +1807,21 @@ files = [ [package.dependencies] cryptography = ">=37.0.0" +[[package]] +name = "types-requests" +version = "2.32.0.20250328" +description = "Typing stubs for requests" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "types_requests-2.32.0.20250328-py3-none-any.whl", hash = "sha256:72ff80f84b15eb3aa7a8e2625fffb6a93f2ad5a0c20215fc1dcfa61117bcb2a2"}, + {file = "types_requests-2.32.0.20250328.tar.gz", hash = "sha256:c9e67228ea103bd811c96984fac36ed2ae8da87a36a633964a21f199d60baf32"}, +] + +[package.dependencies] +urllib3 = ">=2" + [[package]] name = "typing-extensions" version = "4.14.1" diff --git a/pyproject.toml b/pyproject.toml index 50d8aa2a..2d5cf810 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ python = "^3.9.2" paramiko = ">=2.7.1" lxml = ">=4.5.0" requests = "^2.32.3" +types-requests = "^2.32.0.20250328" [tool.poetry.group.dev.dependencies] coverage = ">=7.2" diff --git a/tests/http/openvasd/test_openvasd1.py b/tests/http/openvasd/test_openvasd1.py index c8edc837..7403db7a 100644 --- a/tests/http/openvasd/test_openvasd1.py +++ b/tests/http/openvasd/test_openvasd1.py @@ -20,7 +20,7 @@ def new_mock_empty_response( if status is None: status = int(HTTPStatus.NO_CONTENT) if headers is None: - headers = [] + headers = {} content_type = ContentType.from_string(None) return HttpResponse( body=None, status=status, headers=headers, content_type=content_type @@ -126,7 +126,7 @@ def test_create_scan(self, mock_connector: Mock): # minimal scan response = api.create_scan( {"hosts": "somehost"}, - ["some_vt", "another_vt"], + [{"oid": "some_vt"}, {"oid": "another_vt"}], ) mock_connector.post_json.assert_called_once_with( "/scans", @@ -142,7 +142,7 @@ def test_create_scan(self, mock_connector: Mock): mock_connector.post_json.reset_mock() response = api.create_scan( {"hosts": "somehost"}, - ["some_vt", "another_vt"], + [{"oid": "some_vt"}, {"oid": "another_vt"}], {"my_scanner_param": "abc"}, ) mock_connector.post_json.assert_called_once_with( From ac4d6075fa74901dac4114f9af877b36ed98ac30 Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 10:43:46 +0200 Subject: [PATCH 10/34] Reformat gvm/http/core/_api.py --- gvm/http/core/_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/gvm/http/core/_api.py b/gvm/http/core/_api.py index e8418d0f..beb25b73 100644 --- a/gvm/http/core/_api.py +++ b/gvm/http/core/_api.py @@ -27,7 +27,6 @@ def __init__( api_key: Optional API key for authentication """ - self._connector: HttpApiConnector = connector "The connector handling the HTTP(S) connection" From 318e064f5a7b2cf686ac6b817feabb2433d4f429 Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 10:47:47 +0200 Subject: [PATCH 11/34] Use correct vts list in test_create_scan assertions --- tests/http/openvasd/test_openvasd1.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/http/openvasd/test_openvasd1.py b/tests/http/openvasd/test_openvasd1.py index 7403db7a..d3677409 100644 --- a/tests/http/openvasd/test_openvasd1.py +++ b/tests/http/openvasd/test_openvasd1.py @@ -132,7 +132,7 @@ def test_create_scan(self, mock_connector: Mock): "/scans", { "target": {"hosts": "somehost"}, - "vts": ["some_vt", "another_vt"], + "vts": [{"oid": "some_vt"}, {"oid": "another_vt"}], }, raise_for_status=False, ) @@ -149,7 +149,7 @@ def test_create_scan(self, mock_connector: Mock): "/scans", { "target": {"hosts": "somehost"}, - "vts": ["some_vt", "another_vt"], + "vts": [{"oid": "some_vt"}, {"oid": "another_vt"}], "scan_preferences": {"my_scanner_param": "abc"}, }, raise_for_status=False, From 7777f2ec88f9c06aee94c9f5272bc011f00081af Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 10:50:29 +0200 Subject: [PATCH 12/34] Reorganize imports --- gvm/http/core/headers.py | 2 +- gvm/http/core/response.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/gvm/http/core/headers.py b/gvm/http/core/headers.py index 3ecec720..1af22eaf 100644 --- a/gvm/http/core/headers.py +++ b/gvm/http/core/headers.py @@ -7,7 +7,7 @@ """ from dataclasses import dataclass -from typing import Dict, Optional, TypeVar, Type, Union +from typing import Dict, Optional, Type, TypeVar, Union Self = TypeVar("Self", bound="ContentType") diff --git a/gvm/http/core/response.py b/gvm/http/core/response.py index 2436bd1c..b1579d21 100644 --- a/gvm/http/core/response.py +++ b/gvm/http/core/response.py @@ -7,14 +7,13 @@ """ from dataclasses import dataclass -from typing import Any, Dict, Optional, TypeVar, Type, Union +from typing import Any, Dict, Optional, Type, TypeVar, Union from requests import Response from requests.structures import CaseInsensitiveDict from gvm.http.core.headers import ContentType - Self = TypeVar("Self", bound="HttpResponse") From 39a0f85a4d22f6e60a5ddd837b644b26bc2f871e Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 10:57:09 +0200 Subject: [PATCH 13/34] Make type hints for mock HTTP responses work in older Python --- tests/http/core/test_connector.py | 6 +++--- tests/http/openvasd/test_openvasd1.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/http/core/test_connector.py b/tests/http/core/test_connector.py index 22cfb2e0..bf5c4fb8 100644 --- a/tests/http/core/test_connector.py +++ b/tests/http/core/test_connector.py @@ -4,7 +4,7 @@ import json import unittest from http import HTTPStatus -from typing import Any, Optional +from typing import Any, Optional, Union from unittest.mock import MagicMock, Mock, patch import requests as requests_lib @@ -36,7 +36,7 @@ def new_mock_empty_response( - status: Optional[int | HTTPStatus] = None, + status: Optional[Union[int, HTTPStatus]] = None, ) -> requests_lib.Response: # pylint: disable=protected-access response = requests_lib.Response() @@ -50,7 +50,7 @@ def new_mock_empty_response( def new_mock_json_response( content: Optional[Any] = None, - status: Optional[int | HTTPStatus] = None, + status: Optional[Union[int, HTTPStatus]] = None, ) -> requests_lib.Response: # pylint: disable=protected-access response = requests_lib.Response() diff --git a/tests/http/openvasd/test_openvasd1.py b/tests/http/openvasd/test_openvasd1.py index d3677409..c5bd90f5 100644 --- a/tests/http/openvasd/test_openvasd1.py +++ b/tests/http/openvasd/test_openvasd1.py @@ -4,7 +4,7 @@ import unittest from http import HTTPStatus -from typing import Optional +from typing import Optional, Union from unittest.mock import Mock, patch from gvm.errors import InvalidArgumentType @@ -14,7 +14,7 @@ def new_mock_empty_response( - status: Optional[int | HTTPStatus] = None, + status: Optional[Union[int, HTTPStatus]] = None, headers: Optional[dict[str, str]] = None, ): if status is None: From 14000fa0e4eee0e955543d5bbf457b0c1d68cd17 Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 13:45:48 +0200 Subject: [PATCH 14/34] Move types-requests to dev dependencies --- poetry.lock | 2 +- pyproject.toml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/poetry.lock b/poetry.lock index e520b441..4536d289 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1813,7 +1813,7 @@ version = "2.32.0.20250328" description = "Typing stubs for requests" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["dev"] files = [ {file = "types_requests-2.32.0.20250328-py3-none-any.whl", hash = "sha256:72ff80f84b15eb3aa7a8e2625fffb6a93f2ad5a0c20215fc1dcfa61117bcb2a2"}, {file = "types_requests-2.32.0.20250328.tar.gz", hash = "sha256:c9e67228ea103bd811c96984fac36ed2ae8da87a36a633964a21f199d60baf32"}, diff --git a/pyproject.toml b/pyproject.toml index 2d5cf810..50d8aa2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,6 @@ python = "^3.9.2" paramiko = ">=2.7.1" lxml = ">=4.5.0" requests = "^2.32.3" -types-requests = "^2.32.0.20250328" [tool.poetry.group.dev.dependencies] coverage = ">=7.2" From c7bc65721b0932d74205addc8b88e70d4e08e8df Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 11 Apr 2025 09:31:43 +0200 Subject: [PATCH 15/34] Use httpx library instead of requests --- gvm/http/core/connector.py | 54 ++++--- gvm/http/core/response.py | 7 +- poetry.lock | 17 +- pyproject.toml | 2 +- tests/http/core/test_connector.py | 256 ++++++++++++++++-------------- 5 files changed, 183 insertions(+), 153 deletions(-) diff --git a/gvm/http/core/connector.py b/gvm/http/core/connector.py index 7687a3b9..962a0643 100644 --- a/gvm/http/core/connector.py +++ b/gvm/http/core/connector.py @@ -7,9 +7,9 @@ """ import urllib.parse -from typing import Any, Dict, Optional, Tuple, Union +from typing import Any, MutableMapping, Optional, Tuple, Union -from requests import Session +from httpx import Client from gvm.http.core.response import HttpResponse @@ -20,11 +20,18 @@ class HttpApiConnector: """ @classmethod - def _new_session(cls): + def _new_client( + cls, + server_ca_path: Optional[str] = None, + client_cert_paths: Optional[Union[str, Tuple[str]]] = None, + ): """ - Creates a new session + Creates a new httpx client """ - return Session() + return Client( + verify=server_ca_path if server_ca_path else False, + cert=client_cert_paths, + ) @classmethod def url_join(cls, base: str, rel_path: str) -> str: @@ -63,29 +70,26 @@ def __init__( self.base_url = base_url "The base server URL to which request-specific paths will be appended for the requests" - self._session = self._new_session() - "Internal session handling the HTTP requests" - if server_ca_path: - self._session.verify = server_ca_path - if client_cert_paths: - self._session.cert = client_cert_paths + self._client: Client = self._new_client( + server_ca_path, client_cert_paths + ) - def update_headers(self, new_headers: Dict[str, str]) -> None: + def update_headers(self, new_headers: MutableMapping[str, str]) -> None: """ Updates the headers sent with each request, e.g. for passing an API key Args: - new_headers: Dict containing the new headers + new_headers: MutableMapping, e.g. dict, containing the new headers """ - self._session.headers.update(new_headers) + self._client.headers.update(new_headers) def delete( self, rel_path: str, *, raise_for_status: bool = True, - params: Optional[Dict[str, str]] = None, - headers: Optional[Dict[str, str]] = None, + params: Optional[MutableMapping[str, str]] = None, + headers: Optional[MutableMapping[str, str]] = None, ) -> HttpResponse: """ Sends a ``DELETE`` request and returns the response. @@ -94,14 +98,14 @@ def delete( rel_path: The relative path for the request raise_for_status: Whether to raise an error if response has a non-success HTTP status code - params: Optional dict of URL-encoded parameters + params: Optional MutableMapping, e.g. dict of URL-encoded parameters headers: Optional additional headers added to the request Return: The HTTP response. """ url = self.url_join(self.base_url, rel_path) - r = self._session.delete(url, params=params, headers=headers) + r = self._client.delete(url, params=params, headers=headers) if raise_for_status: r.raise_for_status() return HttpResponse.from_requests_lib(r) @@ -111,8 +115,8 @@ def get( rel_path: str, *, raise_for_status: bool = True, - params: Optional[Dict[str, str]] = None, - headers: Optional[Dict[str, str]] = None, + params: Optional[MutableMapping[str, str]] = None, + headers: Optional[MutableMapping[str, str]] = None, ) -> HttpResponse: """ Sends a ``GET`` request and returns the response. @@ -128,7 +132,7 @@ def get( The HTTP response. """ url = self.url_join(self.base_url, rel_path) - r = self._session.get(url, params=params, headers=headers) + r = self._client.get(url, params=params, headers=headers) if raise_for_status: r.raise_for_status() return HttpResponse.from_requests_lib(r) @@ -139,8 +143,8 @@ def post_json( json: Any, *, raise_for_status: bool = True, - params: Optional[Dict[str, str]] = None, - headers: Optional[Dict[str, str]] = None, + params: Optional[MutableMapping[str, str]] = None, + headers: Optional[MutableMapping[str, str]] = None, ) -> HttpResponse: """ Sends a ``POST`` request, using the given JSON-compatible object as the @@ -151,14 +155,14 @@ def post_json( json: The object to use as the request body. raise_for_status: Whether to raise an error if response has a non-success HTTP status code - params: Optional dict of URL-encoded parameters + params: Optional MutableMapping, e.g. dict of URL-encoded parameters headers: Optional additional headers added to the request Return: The HTTP response. """ url = self.url_join(self.base_url, rel_path) - r = self._session.post(url, json=json, params=params, headers=headers) + r = self._client.post(url, json=json, params=params, headers=headers) if raise_for_status: r.raise_for_status() return HttpResponse.from_requests_lib(r) diff --git a/gvm/http/core/response.py b/gvm/http/core/response.py index b1579d21..34216063 100644 --- a/gvm/http/core/response.py +++ b/gvm/http/core/response.py @@ -7,10 +7,9 @@ """ from dataclasses import dataclass -from typing import Any, Dict, Optional, Type, TypeVar, Union +from typing import Any, MutableMapping, Optional, Type, TypeVar -from requests import Response -from requests.structures import CaseInsensitiveDict +from httpx import Response from gvm.http.core.headers import ContentType @@ -29,7 +28,7 @@ class HttpResponse: status: int "HTTP status code of the response" - headers: Union[Dict[str, str], CaseInsensitiveDict[str]] + headers: MutableMapping[str, str] "Dict containing the headers of the response" content_type: Optional[ContentType] diff --git a/poetry.lock b/poetry.lock index 4536d289..6670a546 100644 --- a/poetry.lock +++ b/poetry.lock @@ -18,7 +18,7 @@ version = "4.9.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, @@ -350,7 +350,7 @@ version = "3.4.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, @@ -647,7 +647,7 @@ version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, @@ -744,7 +744,7 @@ version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -766,7 +766,7 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -1443,7 +1443,7 @@ version = "2.32.4" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, @@ -1552,7 +1552,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -1833,6 +1833,7 @@ files = [ {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, ] +markers = {main = "python_version < \"3.13\""} [[package]] name = "urllib3" @@ -1840,7 +1841,7 @@ version = "2.5.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, diff --git a/pyproject.toml b/pyproject.toml index 50d8aa2a..e5cda5c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ packages = [{ include = "gvm" }, { include = "tests", format = "sdist" }] python = "^3.9.2" paramiko = ">=2.7.1" lxml = ">=4.5.0" -requests = "^2.32.3" +httpx = "^0.28.1" [tool.poetry.group.dev.dependencies] coverage = ">=7.2" diff --git a/tests/http/core/test_connector.py b/tests/http/core/test_connector.py index bf5c4fb8..c91af063 100644 --- a/tests/http/core/test_connector.py +++ b/tests/http/core/test_connector.py @@ -1,14 +1,15 @@ # SPDX-FileCopyrightText: 2025 Greenbone AG # # SPDX-License-Identifier: GPL-3.0-or-later + import json import unittest from http import HTTPStatus from typing import Any, Optional, Union from unittest.mock import MagicMock, Mock, patch -import requests as requests_lib -from requests.exceptions import HTTPError +import httpx +from httpx import HTTPError from gvm.http.core.connector import HttpApiConnector from gvm.http.core.headers import ContentType @@ -18,6 +19,8 @@ "x-example": "some-test-header", } +JSON_EXTRA_HEADERS = {"content-length": "26"} + TEST_JSON_CONTENT_TYPE = ContentType( media_type="application/json", params={"charset": "utf-8"}, @@ -35,44 +38,52 @@ TEST_JSON_REQUEST_BODY = {"request_number": 5} -def new_mock_empty_response( +def new_mock_empty_response_func( + method: str, status: Optional[Union[int, HTTPStatus]] = None, -) -> requests_lib.Response: - # pylint: disable=protected-access - response = requests_lib.Response() - response._content = b"" - if status is None: - response.status_code = int(HTTPStatus.NO_CONTENT) - else: - response.status_code = int(status) - return response +) -> httpx.Response: + def response_func(request_url, *_args, **_kwargs): + response = httpx.Response( + request=httpx.Request(method, request_url), + content=b"", + status_code=( + int(HTTPStatus.NO_CONTENT) if status is None else int(status) + ), + ) + return response + + return response_func -def new_mock_json_response( +def new_mock_json_response_func( + method: str, content: Optional[Any] = None, status: Optional[Union[int, HTTPStatus]] = None, -) -> requests_lib.Response: - # pylint: disable=protected-access - response = requests_lib.Response() - response._content = json.dumps(content).encode() - - if status is None: - response.status_code = int(HTTPStatus.OK) - else: - response.status_code = int(status) +) -> httpx.Response: + def response_func(request_url, *_args, **_kwargs): + response = httpx.Response( + request=httpx.Request(method, request_url), + content=json.dumps(content).encode(), + status_code=int(HTTPStatus.OK) if status is None else int(status), + headers=TEST_JSON_HEADERS, + ) + return response - response.headers.update(TEST_JSON_HEADERS) - return response + return response_func -def new_mock_session(*, headers: Optional[dict] = None) -> Mock: - mock = Mock(spec=requests_lib.Session) +def new_mock_client(*, headers: Optional[dict] = None) -> Mock: + mock = Mock(spec=httpx.Client) mock.headers = headers if headers is not None else {} return mock class HttpApiConnectorTestCase(unittest.TestCase): # pylint: disable=protected-access + def assertHasHeaders(self, expected_headers, actual_headers): + self.assertEqual( + expected_headers | dict(actual_headers), actual_headers + ) def test_url_join(self): self.assertEqual( @@ -101,21 +112,22 @@ def test_url_join(self): ) def test_new_session(self): - new_session = HttpApiConnector._new_session() - self.assertIsInstance(new_session, requests_lib.Session) + new_client = HttpApiConnector._new_client() + self.assertIsInstance(new_client, httpx.Client) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_basic_init(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_basic_init(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() connector = HttpApiConnector("http://localhost") self.assertEqual("http://localhost", connector.base_url) - self.assertEqual(mock_session, connector._session) + new_client_mock.assert_called_once_with(None, None) + self.assertEqual({}, mock_client.headers) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_https_init(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_https_init(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() connector = HttpApiConnector( "https://localhost", @@ -124,13 +136,12 @@ def test_https_init(self, new_session_mock: MagicMock): ) self.assertEqual("https://localhost", connector.base_url) - self.assertEqual(mock_session, connector._session) - self.assertEqual("foo.crt", mock_session.verify) - self.assertEqual("bar.key", mock_session.cert) + new_client_mock.assert_called_once_with("foo.crt", "bar.key") + self.assertEqual({}, mock_client.headers) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_update_headers(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_update_headers(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() connector = HttpApiConnector( "http://localhost", @@ -138,14 +149,14 @@ def test_update_headers(self, new_session_mock: MagicMock): connector.update_headers({"x-foo": "bar"}) connector.update_headers({"x-baz": "123"}) - self.assertEqual({"x-foo": "bar", "x-baz": "123"}, mock_session.headers) + self.assertEqual({"x-foo": "bar", "x-baz": "123"}, mock_client.headers) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_delete(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_delete(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.delete.return_value = new_mock_json_response( - TEST_JSON_RESPONSE_BODY + mock_client.delete.side_effect = new_mock_json_response_func( + "DELETE", TEST_JSON_RESPONSE_BODY ) connector = HttpApiConnector("https://localhost") response = connector.delete( @@ -155,19 +166,21 @@ def test_delete(self, new_session_mock: MagicMock): self.assertEqual(int(HTTPStatus.OK), response.status) self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) - self.assertEqual(TEST_JSON_HEADERS, response.headers) + self.assertEqual( + TEST_JSON_HEADERS | JSON_EXTRA_HEADERS, response.headers + ) - mock_session.delete.assert_called_once_with( + mock_client.delete.assert_called_once_with( "https://localhost/foo", params={"bar": "123"}, headers={"baz": "456"}, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_minimal_delete(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_minimal_delete(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.delete.return_value = new_mock_empty_response() + mock_client.delete.side_effect = new_mock_empty_response_func("DELETE") connector = HttpApiConnector("https://localhost") response = connector.delete("foo") @@ -176,29 +189,30 @@ def test_minimal_delete(self, new_session_mock: MagicMock): self.assertEqual(TEST_EMPTY_CONTENT_TYPE, response.content_type) self.assertEqual({}, response.headers) - mock_session.delete.assert_called_once_with( + mock_client.delete.assert_called_once_with( "https://localhost/foo", params=None, headers=None ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_delete_raise_on_status(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_delete_raise_on_status(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.delete.return_value = new_mock_empty_response( - HTTPStatus.INTERNAL_SERVER_ERROR + mock_client.delete.side_effect = new_mock_empty_response_func( + "DELETE", HTTPStatus.INTERNAL_SERVER_ERROR ) connector = HttpApiConnector("https://localhost") - self.assertRaises(HTTPError, connector.delete, "foo") + self.assertRaises(httpx.HTTPError, connector.delete, "foo") - mock_session.delete.assert_called_once_with( + mock_client.delete.assert_called_once_with( "https://localhost/foo", params=None, headers=None ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_delete_no_raise_on_status(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_delete_no_raise_on_status(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.delete.return_value = new_mock_json_response( + mock_client.delete.side_effect = new_mock_json_response_func( + "DELETE", content=TEST_JSON_RESPONSE_BODY, status=HTTPStatus.INTERNAL_SERVER_ERROR, ) @@ -213,20 +227,22 @@ def test_delete_no_raise_on_status(self, new_session_mock: MagicMock): self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) - self.assertEqual(TEST_JSON_HEADERS, response.headers) + self.assertEqual( + TEST_JSON_HEADERS | JSON_EXTRA_HEADERS, response.headers + ) - mock_session.delete.assert_called_once_with( + mock_client.delete.assert_called_once_with( "https://localhost/foo", params={"bar": "123"}, headers={"baz": "456"}, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_get(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_get(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.get.return_value = new_mock_json_response( - TEST_JSON_RESPONSE_BODY + mock_client.get.side_effect = new_mock_json_response_func( + "GET", TEST_JSON_RESPONSE_BODY ) connector = HttpApiConnector("https://localhost") response = connector.get( @@ -236,19 +252,21 @@ def test_get(self, new_session_mock: MagicMock): self.assertEqual(int(HTTPStatus.OK), response.status) self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) - self.assertEqual(TEST_JSON_HEADERS, response.headers) + self.assertEqual( + TEST_JSON_HEADERS | JSON_EXTRA_HEADERS, response.headers + ) - mock_session.get.assert_called_once_with( + mock_client.get.assert_called_once_with( "https://localhost/foo", params={"bar": "123"}, headers={"baz": "456"}, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_minimal_get(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_minimal_get(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.get.return_value = new_mock_empty_response() + mock_client.get.side_effect = new_mock_empty_response_func("GET") connector = HttpApiConnector("https://localhost") response = connector.get("foo") @@ -257,29 +275,30 @@ def test_minimal_get(self, new_session_mock: MagicMock): self.assertEqual(TEST_EMPTY_CONTENT_TYPE, response.content_type) self.assertEqual({}, response.headers) - mock_session.get.assert_called_once_with( + mock_client.get.assert_called_once_with( "https://localhost/foo", params=None, headers=None ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_get_raise_on_status(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_get_raise_on_status(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.get.return_value = new_mock_empty_response( - HTTPStatus.INTERNAL_SERVER_ERROR + mock_client.get.side_effect = new_mock_empty_response_func( + "GET", HTTPStatus.INTERNAL_SERVER_ERROR ) connector = HttpApiConnector("https://localhost") self.assertRaises(HTTPError, connector.get, "foo") - mock_session.get.assert_called_once_with( + mock_client.get.assert_called_once_with( "https://localhost/foo", params=None, headers=None ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_get_no_raise_on_status(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_get_no_raise_on_status(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.get.return_value = new_mock_json_response( + mock_client.get.side_effect = new_mock_json_response_func( + "GET", content=TEST_JSON_RESPONSE_BODY, status=HTTPStatus.INTERNAL_SERVER_ERROR, ) @@ -294,20 +313,22 @@ def test_get_no_raise_on_status(self, new_session_mock: MagicMock): self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) - self.assertEqual(TEST_JSON_HEADERS, response.headers) + self.assertEqual( + TEST_JSON_HEADERS | JSON_EXTRA_HEADERS, response.headers + ) - mock_session.get.assert_called_once_with( + mock_client.get.assert_called_once_with( "https://localhost/foo", params={"bar": "123"}, headers={"baz": "456"}, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_post_json(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_post_json(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.post.return_value = new_mock_json_response( - TEST_JSON_RESPONSE_BODY + mock_client.post.side_effect = new_mock_json_response_func( + "POST", TEST_JSON_RESPONSE_BODY ) connector = HttpApiConnector("https://localhost") response = connector.post_json( @@ -320,20 +341,22 @@ def test_post_json(self, new_session_mock: MagicMock): self.assertEqual(int(HTTPStatus.OK), response.status) self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) - self.assertEqual(TEST_JSON_HEADERS, response.headers) + self.assertEqual( + TEST_JSON_HEADERS | JSON_EXTRA_HEADERS, response.headers + ) - mock_session.post.assert_called_once_with( + mock_client.post.assert_called_once_with( "https://localhost/foo", json={"number": 5}, params={"bar": "123"}, headers={"baz": "456"}, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_minimal_post_json(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_minimal_post_json(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.post.return_value = new_mock_empty_response() + mock_client.post.side_effect = new_mock_empty_response_func("POST") connector = HttpApiConnector("https://localhost") response = connector.post_json("foo", TEST_JSON_REQUEST_BODY) @@ -342,37 +365,38 @@ def test_minimal_post_json(self, new_session_mock: MagicMock): self.assertEqual(TEST_EMPTY_CONTENT_TYPE, response.content_type) self.assertEqual({}, response.headers) - mock_session.post.assert_called_once_with( + mock_client.post.assert_called_once_with( "https://localhost/foo", json=TEST_JSON_REQUEST_BODY, params=None, headers=None, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_post_json_raise_on_status(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_post_json_raise_on_status(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.post.return_value = new_mock_empty_response( - HTTPStatus.INTERNAL_SERVER_ERROR + mock_client.post.side_effect = new_mock_empty_response_func( + "POST", HTTPStatus.INTERNAL_SERVER_ERROR ) connector = HttpApiConnector("https://localhost") self.assertRaises( HTTPError, connector.post_json, "foo", json=TEST_JSON_REQUEST_BODY ) - mock_session.post.assert_called_once_with( + mock_client.post.assert_called_once_with( "https://localhost/foo", json=TEST_JSON_REQUEST_BODY, params=None, headers=None, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_post_json_no_raise_on_status(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_post_json_no_raise_on_status(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.post.return_value = new_mock_json_response( + mock_client.post.side_effect = new_mock_json_response_func( + "POST", content=TEST_JSON_RESPONSE_BODY, status=HTTPStatus.INTERNAL_SERVER_ERROR, ) @@ -388,9 +412,11 @@ def test_post_json_no_raise_on_status(self, new_session_mock: MagicMock): self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) - self.assertEqual(TEST_JSON_HEADERS, response.headers) + self.assertEqual( + TEST_JSON_HEADERS | JSON_EXTRA_HEADERS, response.headers + ) - mock_session.post.assert_called_once_with( + mock_client.post.assert_called_once_with( "https://localhost/foo", json=TEST_JSON_REQUEST_BODY, params={"bar": "123"}, From 893cb7f1f22317b7e591a723c3934386dfe05527 Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 11 Apr 2025 11:42:34 +0200 Subject: [PATCH 16/34] Move http package into protocols --- docs/api/http.rst | 2 +- docs/api/httpcore.rst | 6 +-- docs/api/openvasdv1.rst | 2 +- gvm/{ => protocols}/http/__init__.py | 0 gvm/{ => protocols}/http/core/__init__.py | 0 gvm/{ => protocols}/http/core/_api.py | 2 +- gvm/{ => protocols}/http/core/connector.py | 2 +- gvm/{ => protocols}/http/core/headers.py | 0 gvm/{ => protocols}/http/core/response.py | 2 +- gvm/{ => protocols}/http/openvasd/__init__.py | 0 .../http/openvasd/openvasd1.py | 6 +-- tests/{ => protocols}/http/__init__.py | 0 tests/{ => protocols}/http/core/__init__.py | 0 tests/{ => protocols}/http/core/test_api.py | 2 +- .../http/core/test_connector.py | 4 +- .../{ => protocols}/http/core/test_headers.py | 2 +- .../http/core/test_response.py | 2 +- .../{ => protocols}/http/openvasd/__init__.py | 0 .../http/openvasd/test_openvasd1.py | 50 +++++++++---------- 19 files changed, 41 insertions(+), 41 deletions(-) rename gvm/{ => protocols}/http/__init__.py (100%) rename gvm/{ => protocols}/http/core/__init__.py (100%) rename gvm/{ => protocols}/http/core/_api.py (92%) rename gvm/{ => protocols}/http/core/connector.py (98%) rename gvm/{ => protocols}/http/core/headers.py (100%) rename gvm/{ => protocols}/http/core/response.py (96%) rename gvm/{ => protocols}/http/openvasd/__init__.py (100%) rename gvm/{ => protocols}/http/openvasd/openvasd1.py (98%) rename tests/{ => protocols}/http/__init__.py (100%) rename tests/{ => protocols}/http/core/__init__.py (100%) rename tests/{ => protocols}/http/core/test_api.py (93%) rename tests/{ => protocols}/http/core/test_connector.py (99%) rename tests/{ => protocols}/http/core/test_headers.py (96%) rename tests/{ => protocols}/http/core/test_response.py (97%) rename tests/{ => protocols}/http/openvasd/__init__.py (100%) rename tests/{ => protocols}/http/openvasd/test_openvasd1.py (86%) diff --git a/docs/api/http.rst b/docs/api/http.rst index 50b52238..5f14d5dd 100644 --- a/docs/api/http.rst +++ b/docs/api/http.rst @@ -3,7 +3,7 @@ HTTP APIs --------- -.. automodule:: gvm.http +.. automodule:: gvm.protocols.http .. toctree:: :maxdepth: 1 diff --git a/docs/api/httpcore.rst b/docs/api/httpcore.rst index f94fb4af..c06b9049 100644 --- a/docs/api/httpcore.rst +++ b/docs/api/httpcore.rst @@ -6,7 +6,7 @@ HTTP core classes Connector ######### -.. automodule:: gvm.http.core.connector +.. automodule:: gvm.protocols.http.core.connector .. autoclass:: HttpApiConnector :members: @@ -14,7 +14,7 @@ Connector Headers ####### -.. automodule:: gvm.http.core.headers +.. automodule:: gvm.protocols.http.core.headers .. autoclass:: ContentType :members: @@ -22,7 +22,7 @@ Headers Response ######## -.. automodule:: gvm.http.core.response +.. automodule:: gvm.protocols.http.core.response .. autoclass:: HttpResponse :members: \ No newline at end of file diff --git a/docs/api/openvasdv1.rst b/docs/api/openvasdv1.rst index 2a3699c9..2b061f8a 100644 --- a/docs/api/openvasdv1.rst +++ b/docs/api/openvasdv1.rst @@ -3,7 +3,7 @@ openvasd v1 ^^^^^^^^^^^ -.. automodule:: gvm.http.openvasd.openvasd1 +.. automodule:: gvm.protocols.http.openvasd.openvasd1 .. autoclass:: OpenvasdHttpApiV1 :members: \ No newline at end of file diff --git a/gvm/http/__init__.py b/gvm/protocols/http/__init__.py similarity index 100% rename from gvm/http/__init__.py rename to gvm/protocols/http/__init__.py diff --git a/gvm/http/core/__init__.py b/gvm/protocols/http/core/__init__.py similarity index 100% rename from gvm/http/core/__init__.py rename to gvm/protocols/http/core/__init__.py diff --git a/gvm/http/core/_api.py b/gvm/protocols/http/core/_api.py similarity index 92% rename from gvm/http/core/_api.py rename to gvm/protocols/http/core/_api.py index beb25b73..008fc101 100644 --- a/gvm/http/core/_api.py +++ b/gvm/protocols/http/core/_api.py @@ -8,7 +8,7 @@ from typing import Optional -from gvm.http.core.connector import HttpApiConnector +from gvm.protocols.http.core.connector import HttpApiConnector class GvmHttpApi: diff --git a/gvm/http/core/connector.py b/gvm/protocols/http/core/connector.py similarity index 98% rename from gvm/http/core/connector.py rename to gvm/protocols/http/core/connector.py index 962a0643..e3274575 100644 --- a/gvm/http/core/connector.py +++ b/gvm/protocols/http/core/connector.py @@ -11,7 +11,7 @@ from httpx import Client -from gvm.http.core.response import HttpResponse +from gvm.protocols.http.core.response import HttpResponse class HttpApiConnector: diff --git a/gvm/http/core/headers.py b/gvm/protocols/http/core/headers.py similarity index 100% rename from gvm/http/core/headers.py rename to gvm/protocols/http/core/headers.py diff --git a/gvm/http/core/response.py b/gvm/protocols/http/core/response.py similarity index 96% rename from gvm/http/core/response.py rename to gvm/protocols/http/core/response.py index 34216063..154462f0 100644 --- a/gvm/http/core/response.py +++ b/gvm/protocols/http/core/response.py @@ -11,7 +11,7 @@ from httpx import Response -from gvm.http.core.headers import ContentType +from gvm.protocols.http.core.headers import ContentType Self = TypeVar("Self", bound="HttpResponse") diff --git a/gvm/http/openvasd/__init__.py b/gvm/protocols/http/openvasd/__init__.py similarity index 100% rename from gvm/http/openvasd/__init__.py rename to gvm/protocols/http/openvasd/__init__.py diff --git a/gvm/http/openvasd/openvasd1.py b/gvm/protocols/http/openvasd/openvasd1.py similarity index 98% rename from gvm/http/openvasd/openvasd1.py rename to gvm/protocols/http/openvasd/openvasd1.py index 1255870c..14dd22bb 100644 --- a/gvm/http/openvasd/openvasd1.py +++ b/gvm/protocols/http/openvasd/openvasd1.py @@ -10,9 +10,9 @@ from typing import Any, Optional, Union from gvm.errors import InvalidArgumentType -from gvm.http.core._api import GvmHttpApi -from gvm.http.core.connector import HttpApiConnector -from gvm.http.core.response import HttpResponse +from gvm.protocols.http.core._api import GvmHttpApi +from gvm.protocols.http.core.connector import HttpApiConnector +from gvm.protocols.http.core.response import HttpResponse class OpenvasdHttpApiV1(GvmHttpApi): diff --git a/tests/http/__init__.py b/tests/protocols/http/__init__.py similarity index 100% rename from tests/http/__init__.py rename to tests/protocols/http/__init__.py diff --git a/tests/http/core/__init__.py b/tests/protocols/http/core/__init__.py similarity index 100% rename from tests/http/core/__init__.py rename to tests/protocols/http/core/__init__.py diff --git a/tests/http/core/test_api.py b/tests/protocols/http/core/test_api.py similarity index 93% rename from tests/http/core/test_api.py rename to tests/protocols/http/core/test_api.py index fe3876b1..b9834578 100644 --- a/tests/http/core/test_api.py +++ b/tests/protocols/http/core/test_api.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import MagicMock, patch -from gvm.http.core._api import GvmHttpApi +from gvm.protocols.http.core._api import GvmHttpApi class GvmHttpApiTestCase(unittest.TestCase): diff --git a/tests/http/core/test_connector.py b/tests/protocols/http/core/test_connector.py similarity index 99% rename from tests/http/core/test_connector.py rename to tests/protocols/http/core/test_connector.py index c91af063..129cdb3e 100644 --- a/tests/http/core/test_connector.py +++ b/tests/protocols/http/core/test_connector.py @@ -11,8 +11,8 @@ import httpx from httpx import HTTPError -from gvm.http.core.connector import HttpApiConnector -from gvm.http.core.headers import ContentType +from gvm.protocols.http.core.connector import HttpApiConnector +from gvm.protocols.http.core import ContentType TEST_JSON_HEADERS = { "content-type": "application/json;charset=utf-8", diff --git a/tests/http/core/test_headers.py b/tests/protocols/http/core/test_headers.py similarity index 96% rename from tests/http/core/test_headers.py rename to tests/protocols/http/core/test_headers.py index 07257d73..586856b4 100644 --- a/tests/http/core/test_headers.py +++ b/tests/protocols/http/core/test_headers.py @@ -4,7 +4,7 @@ import unittest -from gvm.http.core.headers import ContentType +from gvm.protocols.http.core import ContentType class ContentTypeTestCase(unittest.TestCase): diff --git a/tests/http/core/test_response.py b/tests/protocols/http/core/test_response.py similarity index 97% rename from tests/http/core/test_response.py rename to tests/protocols/http/core/test_response.py index 5dbbcf67..287a618d 100644 --- a/tests/http/core/test_response.py +++ b/tests/protocols/http/core/test_response.py @@ -7,7 +7,7 @@ import requests as requests_lib -from gvm.http.core.response import HttpResponse +from gvm.protocols.http.core.response import HttpResponse class HttpResponseFromRequestsLibTestCase(unittest.TestCase): diff --git a/tests/http/openvasd/__init__.py b/tests/protocols/http/openvasd/__init__.py similarity index 100% rename from tests/http/openvasd/__init__.py rename to tests/protocols/http/openvasd/__init__.py diff --git a/tests/http/openvasd/test_openvasd1.py b/tests/protocols/http/openvasd/test_openvasd1.py similarity index 86% rename from tests/http/openvasd/test_openvasd1.py rename to tests/protocols/http/openvasd/test_openvasd1.py index c5bd90f5..3b523a26 100644 --- a/tests/http/openvasd/test_openvasd1.py +++ b/tests/protocols/http/openvasd/test_openvasd1.py @@ -8,9 +8,9 @@ from unittest.mock import Mock, patch from gvm.errors import InvalidArgumentType -from gvm.http.core.headers import ContentType -from gvm.http.core.response import HttpResponse -from gvm.http.openvasd.openvasd1 import OpenvasdHttpApiV1 +from gvm.protocols.http.core.headers import ContentType +from gvm.protocols.http.core.response import HttpResponse +from gvm.protocols.http.openvasd.openvasd1 import OpenvasdHttpApiV1 def new_mock_empty_response( @@ -29,13 +29,13 @@ def new_mock_empty_response( class OpenvasdHttpApiV1TestCase(unittest.TestCase): - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_init(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector) mock_connector.update_headers.assert_not_called() self.assertIsNotNone(api) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_init_with_api_key(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector, api_key="my-API-key") mock_connector.update_headers.assert_called_once_with( @@ -43,7 +43,7 @@ def test_init_with_api_key(self, mock_connector: Mock): ) self.assertIsNotNone(api) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_health_alive(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -55,7 +55,7 @@ def test_get_health_alive(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_health_ready(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -67,7 +67,7 @@ def test_get_health_ready(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_health_started(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -79,7 +79,7 @@ def test_get_health_started(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_notus_os_list(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -91,7 +91,7 @@ def test_get_notus_os_list(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_run_notus_scan(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.post_json.return_value = expected_response @@ -105,7 +105,7 @@ def test_run_notus_scan(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_scan_preferences(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -117,7 +117,7 @@ def test_get_scan_preferences(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_create_scan(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.post_json.return_value = expected_response @@ -156,7 +156,7 @@ def test_create_scan(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_delete_scan(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.delete.return_value = expected_response @@ -168,7 +168,7 @@ def test_delete_scan(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_scans(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -180,7 +180,7 @@ def test_get_scans(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_scan(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -192,7 +192,7 @@ def test_get_scan(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_scan_results(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -204,7 +204,7 @@ def test_get_scan_results(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_scan_results_with_ranges(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -239,7 +239,7 @@ def test_get_scan_results_with_ranges(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_scan_results_with_invalid_ranges(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -263,7 +263,7 @@ def test_get_scan_results_with_invalid_ranges(self, mock_connector: Mock): range_end="invalid", ) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_scan_result(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -275,7 +275,7 @@ def test_get_scan_result(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_scan_status(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -287,7 +287,7 @@ def test_get_scan_status(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_run_scan_action(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.post_json.return_value = expected_response @@ -301,7 +301,7 @@ def test_run_scan_action(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_start_scan(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.post_json.return_value = expected_response @@ -313,7 +313,7 @@ def test_start_scan(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_stop_scan(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.post_json.return_value = expected_response @@ -325,7 +325,7 @@ def test_stop_scan(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_vts(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -337,7 +337,7 @@ def test_get_vts(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_vt(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response From 6aa74ff305a51fafe2592dbc8cbc0ae35aef0dec Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 11 Apr 2025 11:52:47 +0200 Subject: [PATCH 17/34] Fix type hints for change to httpx --- gvm/protocols/http/core/connector.py | 4 ++-- gvm/protocols/http/core/response.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gvm/protocols/http/core/connector.py b/gvm/protocols/http/core/connector.py index e3274575..dfae6c06 100644 --- a/gvm/protocols/http/core/connector.py +++ b/gvm/protocols/http/core/connector.py @@ -23,7 +23,7 @@ class HttpApiConnector: def _new_client( cls, server_ca_path: Optional[str] = None, - client_cert_paths: Optional[Union[str, Tuple[str]]] = None, + client_cert_paths: Optional[Union[str, Tuple[str, str]]] = None, ): """ Creates a new httpx client @@ -51,7 +51,7 @@ def __init__( base_url: str, *, server_ca_path: Optional[str] = None, - client_cert_paths: Optional[Union[str, Tuple[str]]] = None, + client_cert_paths: Optional[Union[str, Tuple[str, str]]] = None, ): """ Create a new HTTP API Connector. diff --git a/gvm/protocols/http/core/response.py b/gvm/protocols/http/core/response.py index 154462f0..0ac7a95a 100644 --- a/gvm/protocols/http/core/response.py +++ b/gvm/protocols/http/core/response.py @@ -50,7 +50,7 @@ def from_requests_lib(cls: Type[Self], r: Response) -> "HttpResponse": A non-empty body will be parsed accordingly. """ ct = ContentType.from_string(r.headers.get("content-type")) - body = r.content + body: Optional[bytes] = r.content if r.content == b"": body = None From 41ad4dcf2665963a5a2a5683fc90230910d2628f Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 11 Apr 2025 11:56:55 +0200 Subject: [PATCH 18/34] Move http module in imports and mock patches --- tests/protocols/http/core/test_api.py | 4 +-- tests/protocols/http/core/test_connector.py | 32 ++++++++++----------- tests/protocols/http/core/test_headers.py | 2 +- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/tests/protocols/http/core/test_api.py b/tests/protocols/http/core/test_api.py index b9834578..150d4be6 100644 --- a/tests/protocols/http/core/test_api.py +++ b/tests/protocols/http/core/test_api.py @@ -11,12 +11,12 @@ class GvmHttpApiTestCase(unittest.TestCase): # pylint: disable=protected-access - @patch("gvm.http.core.connector.HttpApiConnector") + @patch("gvm.protocols.http.core.connector.HttpApiConnector") def test_basic_init(self, connector_mock: MagicMock): api = GvmHttpApi(connector_mock) self.assertEqual(connector_mock, api._connector) - @patch("gvm.http.core.connector.HttpApiConnector") + @patch("gvm.protocols.http.core.connector.HttpApiConnector") def test_init_with_key(self, connector_mock: MagicMock): api = GvmHttpApi(connector_mock, api_key="my-api-key") self.assertEqual(connector_mock, api._connector) diff --git a/tests/protocols/http/core/test_connector.py b/tests/protocols/http/core/test_connector.py index 129cdb3e..140ac347 100644 --- a/tests/protocols/http/core/test_connector.py +++ b/tests/protocols/http/core/test_connector.py @@ -12,7 +12,7 @@ from httpx import HTTPError from gvm.protocols.http.core.connector import HttpApiConnector -from gvm.protocols.http.core import ContentType +from gvm.protocols.http.core.headers import ContentType TEST_JSON_HEADERS = { "content-type": "application/json;charset=utf-8", @@ -115,7 +115,7 @@ def test_new_session(self): new_client = HttpApiConnector._new_client() self.assertIsInstance(new_client, httpx.Client) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_basic_init(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -125,7 +125,7 @@ def test_basic_init(self, new_client_mock: MagicMock): new_client_mock.assert_called_once_with(None, None) self.assertEqual({}, mock_client.headers) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_https_init(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -139,7 +139,7 @@ def test_https_init(self, new_client_mock: MagicMock): new_client_mock.assert_called_once_with("foo.crt", "bar.key") self.assertEqual({}, mock_client.headers) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_update_headers(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -151,7 +151,7 @@ def test_update_headers(self, new_client_mock: MagicMock): self.assertEqual({"x-foo": "bar", "x-baz": "123"}, mock_client.headers) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_delete(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -176,7 +176,7 @@ def test_delete(self, new_client_mock: MagicMock): headers={"baz": "456"}, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_minimal_delete(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -193,7 +193,7 @@ def test_minimal_delete(self, new_client_mock: MagicMock): "https://localhost/foo", params=None, headers=None ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_delete_raise_on_status(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -207,7 +207,7 @@ def test_delete_raise_on_status(self, new_client_mock: MagicMock): "https://localhost/foo", params=None, headers=None ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_delete_no_raise_on_status(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -237,7 +237,7 @@ def test_delete_no_raise_on_status(self, new_client_mock: MagicMock): headers={"baz": "456"}, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_get(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -262,7 +262,7 @@ def test_get(self, new_client_mock: MagicMock): headers={"baz": "456"}, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_minimal_get(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -279,7 +279,7 @@ def test_minimal_get(self, new_client_mock: MagicMock): "https://localhost/foo", params=None, headers=None ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_get_raise_on_status(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -293,7 +293,7 @@ def test_get_raise_on_status(self, new_client_mock: MagicMock): "https://localhost/foo", params=None, headers=None ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_get_no_raise_on_status(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -323,7 +323,7 @@ def test_get_no_raise_on_status(self, new_client_mock: MagicMock): headers={"baz": "456"}, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_post_json(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -352,7 +352,7 @@ def test_post_json(self, new_client_mock: MagicMock): headers={"baz": "456"}, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_minimal_post_json(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -372,7 +372,7 @@ def test_minimal_post_json(self, new_client_mock: MagicMock): headers=None, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_post_json_raise_on_status(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -391,7 +391,7 @@ def test_post_json_raise_on_status(self, new_client_mock: MagicMock): headers=None, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_post_json_no_raise_on_status(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() diff --git a/tests/protocols/http/core/test_headers.py b/tests/protocols/http/core/test_headers.py index 586856b4..6e1581f0 100644 --- a/tests/protocols/http/core/test_headers.py +++ b/tests/protocols/http/core/test_headers.py @@ -4,7 +4,7 @@ import unittest -from gvm.protocols.http.core import ContentType +from gvm.protocols.http.core.headers import ContentType class ContentTypeTestCase(unittest.TestCase): From 0c28724465c6a36c087c05facf42e16f81ed7087 Mon Sep 17 00:00:00 2001 From: ozgen Date: Fri, 20 Jun 2025 13:22:53 +0200 Subject: [PATCH 19/34] Remove: remove core package from protocols.http --- docs/api/http.rst | 1 - docs/api/httpcore.rst | 28 -- gvm/protocols/http/core/__init__.py | 7 - gvm/protocols/http/core/_api.py | 34 -- gvm/protocols/http/core/connector.py | 168 ------- gvm/protocols/http/core/headers.py | 63 --- gvm/protocols/http/core/response.py | 61 --- tests/protocols/http/core/__init__.py | 3 - tests/protocols/http/core/test_api.py | 23 - tests/protocols/http/core/test_connector.py | 424 ------------------ tests/protocols/http/core/test_headers.py | 44 -- tests/protocols/http/core/test_response.py | 65 --- .../protocols/http/openvasd/test_openvasd1.py | 350 --------------- 13 files changed, 1271 deletions(-) delete mode 100644 docs/api/httpcore.rst delete mode 100644 gvm/protocols/http/core/__init__.py delete mode 100644 gvm/protocols/http/core/_api.py delete mode 100644 gvm/protocols/http/core/connector.py delete mode 100644 gvm/protocols/http/core/headers.py delete mode 100644 gvm/protocols/http/core/response.py delete mode 100644 tests/protocols/http/core/__init__.py delete mode 100644 tests/protocols/http/core/test_api.py delete mode 100644 tests/protocols/http/core/test_connector.py delete mode 100644 tests/protocols/http/core/test_headers.py delete mode 100644 tests/protocols/http/core/test_response.py delete mode 100644 tests/protocols/http/openvasd/test_openvasd1.py diff --git a/docs/api/http.rst b/docs/api/http.rst index 5f14d5dd..888d9c45 100644 --- a/docs/api/http.rst +++ b/docs/api/http.rst @@ -8,5 +8,4 @@ HTTP APIs .. toctree:: :maxdepth: 1 - httpcore openvasdv1 \ No newline at end of file diff --git a/docs/api/httpcore.rst b/docs/api/httpcore.rst deleted file mode 100644 index c06b9049..00000000 --- a/docs/api/httpcore.rst +++ /dev/null @@ -1,28 +0,0 @@ -.. _httpcore: - -HTTP core classes -^^^^^^^^^^^^^^^^^ - -Connector -######### - -.. automodule:: gvm.protocols.http.core.connector - -.. autoclass:: HttpApiConnector - :members: - -Headers -####### - -.. automodule:: gvm.protocols.http.core.headers - -.. autoclass:: ContentType - :members: - -Response -######## - -.. automodule:: gvm.protocols.http.core.response - -.. autoclass:: HttpResponse - :members: \ No newline at end of file diff --git a/gvm/protocols/http/core/__init__.py b/gvm/protocols/http/core/__init__.py deleted file mode 100644 index d079cfd4..00000000 --- a/gvm/protocols/http/core/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# SPDX-FileCopyrightText: 2025 Greenbone AG -# -# SPDX-License-Identifier: GPL-3.0-or-later - -""" -HTTP core classes -""" diff --git a/gvm/protocols/http/core/_api.py b/gvm/protocols/http/core/_api.py deleted file mode 100644 index 008fc101..00000000 --- a/gvm/protocols/http/core/_api.py +++ /dev/null @@ -1,34 +0,0 @@ -# SPDX-FileCopyrightText: 2025 Greenbone AG -# -# SPDX-License-Identifier: GPL-3.0-or-later - -""" -Base class module for GVM HTTP APIs -""" - -from typing import Optional - -from gvm.protocols.http.core.connector import HttpApiConnector - - -class GvmHttpApi: - """ - Base class for HTTP-based GVM APIs. - """ - - def __init__( - self, connector: HttpApiConnector, *, api_key: Optional[str] = None - ): - """ - Create a new generic GVM HTTP API instance. - - Args: - connector: The connector handling the HTTP(S) connection - api_key: Optional API key for authentication - """ - - self._connector: HttpApiConnector = connector - "The connector handling the HTTP(S) connection" - - self._api_key: Optional[str] = api_key - "Optional API key for authentication" diff --git a/gvm/protocols/http/core/connector.py b/gvm/protocols/http/core/connector.py deleted file mode 100644 index dfae6c06..00000000 --- a/gvm/protocols/http/core/connector.py +++ /dev/null @@ -1,168 +0,0 @@ -# SPDX-FileCopyrightText: 2025 Greenbone AG -# -# SPDX-License-Identifier: GPL-3.0-or-later - -""" -Module for handling GVM HTTP API connections -""" - -import urllib.parse -from typing import Any, MutableMapping, Optional, Tuple, Union - -from httpx import Client - -from gvm.protocols.http.core.response import HttpResponse - - -class HttpApiConnector: - """ - Class for connecting to HTTP based API servers, sending requests and receiving the responses. - """ - - @classmethod - def _new_client( - cls, - server_ca_path: Optional[str] = None, - client_cert_paths: Optional[Union[str, Tuple[str, str]]] = None, - ): - """ - Creates a new httpx client - """ - return Client( - verify=server_ca_path if server_ca_path else False, - cert=client_cert_paths, - ) - - @classmethod - def url_join(cls, base: str, rel_path: str) -> str: - """ - Combines a base URL and a relative path into one URL. - - Unlike `urrlib.parse.urljoin` the base path will always be the parent of the - relative path as if it ends with "/". - """ - if base.endswith("/"): - return urllib.parse.urljoin(base, rel_path) - - return urllib.parse.urljoin(base + "/", rel_path) - - def __init__( - self, - base_url: str, - *, - server_ca_path: Optional[str] = None, - client_cert_paths: Optional[Union[str, Tuple[str, str]]] = None, - ): - """ - Create a new HTTP API Connector. - - Args: - base_url: The base server URL to which request-specific paths will be appended - for the requests - server_ca_path: Optional path to a CA certificate for verifying the server. - If none is given, server verification is disabled. - client_cert_paths: Optional path to a client private key and certificate - for authentication. - Can be a combined key and certificate file or a tuple containing separate files. - The key must not be encrypted. - """ - - self.base_url = base_url - "The base server URL to which request-specific paths will be appended for the requests" - - self._client: Client = self._new_client( - server_ca_path, client_cert_paths - ) - - def update_headers(self, new_headers: MutableMapping[str, str]) -> None: - """ - Updates the headers sent with each request, e.g. for passing an API key - - Args: - new_headers: MutableMapping, e.g. dict, containing the new headers - """ - self._client.headers.update(new_headers) - - def delete( - self, - rel_path: str, - *, - raise_for_status: bool = True, - params: Optional[MutableMapping[str, str]] = None, - headers: Optional[MutableMapping[str, str]] = None, - ) -> HttpResponse: - """ - Sends a ``DELETE`` request and returns the response. - - Args: - rel_path: The relative path for the request - raise_for_status: Whether to raise an error if response has a - non-success HTTP status code - params: Optional MutableMapping, e.g. dict of URL-encoded parameters - headers: Optional additional headers added to the request - - Return: - The HTTP response. - """ - url = self.url_join(self.base_url, rel_path) - r = self._client.delete(url, params=params, headers=headers) - if raise_for_status: - r.raise_for_status() - return HttpResponse.from_requests_lib(r) - - def get( - self, - rel_path: str, - *, - raise_for_status: bool = True, - params: Optional[MutableMapping[str, str]] = None, - headers: Optional[MutableMapping[str, str]] = None, - ) -> HttpResponse: - """ - Sends a ``GET`` request and returns the response. - - Args: - rel_path: The relative path for the request - raise_for_status: Whether to raise an error if response has a - non-success HTTP status code - params: Optional dict of URL-encoded parameters - headers: Optional additional headers added to the request - - Return: - The HTTP response. - """ - url = self.url_join(self.base_url, rel_path) - r = self._client.get(url, params=params, headers=headers) - if raise_for_status: - r.raise_for_status() - return HttpResponse.from_requests_lib(r) - - def post_json( - self, - rel_path: str, - json: Any, - *, - raise_for_status: bool = True, - params: Optional[MutableMapping[str, str]] = None, - headers: Optional[MutableMapping[str, str]] = None, - ) -> HttpResponse: - """ - Sends a ``POST`` request, using the given JSON-compatible object as the - request body, and returns the response. - - Args: - rel_path: The relative path for the request - json: The object to use as the request body. - raise_for_status: Whether to raise an error if response has a - non-success HTTP status code - params: Optional MutableMapping, e.g. dict of URL-encoded parameters - headers: Optional additional headers added to the request - - Return: - The HTTP response. - """ - url = self.url_join(self.base_url, rel_path) - r = self._client.post(url, json=json, params=params, headers=headers) - if raise_for_status: - r.raise_for_status() - return HttpResponse.from_requests_lib(r) diff --git a/gvm/protocols/http/core/headers.py b/gvm/protocols/http/core/headers.py deleted file mode 100644 index 1af22eaf..00000000 --- a/gvm/protocols/http/core/headers.py +++ /dev/null @@ -1,63 +0,0 @@ -# SPDX-FileCopyrightText: 2025 Greenbone AG -# -# SPDX-License-Identifier: GPL-3.0-or-later - -""" -Module for handling special HTTP headers -""" - -from dataclasses import dataclass -from typing import Dict, Optional, Type, TypeVar, Union - -Self = TypeVar("Self", bound="ContentType") - - -@dataclass -class ContentType: - """ - Class representing the content type of a HTTP response. - """ - - media_type: str - 'The MIME media type, e.g. "application/json"' - - params: Dict[str, Union[bool, str]] - "Dictionary of parameters in the content type header" - - charset: Optional[str] - "The charset parameter in the content type header if it is set" - - @classmethod - def from_string( - cls: Type[Self], - header_string: Optional[str], - fallback_media_type: str = "application/octet-stream", - ) -> "ContentType": - """ - Parse the content of content type header into a ContentType object. - - Args: - header_string: The string to parse - fallback_media_type: The media type to use if the `header_string` is `None` or empty. - """ - media_type = fallback_media_type - params: Dict[str, Union[bool, str]] = {} - charset = None - - if header_string: - parts = header_string.split(";") - if len(parts) > 0: - media_type = parts[0].strip() - for part in parts[1:]: - param = part.strip() - if "=" in param: - key, value = map(lambda x: x.strip(), param.split("=", 1)) - params[key] = value - if key == "charset": - charset = value - else: - params[param] = True - - return ContentType( - media_type=media_type, params=params, charset=charset - ) diff --git a/gvm/protocols/http/core/response.py b/gvm/protocols/http/core/response.py deleted file mode 100644 index 0ac7a95a..00000000 --- a/gvm/protocols/http/core/response.py +++ /dev/null @@ -1,61 +0,0 @@ -# SPDX-FileCopyrightText: 2025 Greenbone AG -# -# SPDX-License-Identifier: GPL-3.0-or-later - -""" -Module for abstracting HTTP responses -""" - -from dataclasses import dataclass -from typing import Any, MutableMapping, Optional, Type, TypeVar - -from httpx import Response - -from gvm.protocols.http.core.headers import ContentType - -Self = TypeVar("Self", bound="HttpResponse") - - -@dataclass -class HttpResponse: - """ - Class representing an HTTP response. - """ - - body: Any - "The body of the response" - - status: int - "HTTP status code of the response" - - headers: MutableMapping[str, str] - "Dict containing the headers of the response" - - content_type: Optional[ContentType] - "The content type of the response if it was included in the headers" - - @classmethod - def from_requests_lib(cls: Type[Self], r: Response) -> "HttpResponse": - """ - Creates a new HTTP response object from a Request object created by the "Requests" library. - - Args: - r: The request object to convert. - - Return: - A HttpResponse object representing the response. - - An empty body is represented by None. - If the content-type header in the response is set to 'application/json'. - A non-empty body will be parsed accordingly. - """ - ct = ContentType.from_string(r.headers.get("content-type")) - body: Optional[bytes] = r.content - - if r.content == b"": - body = None - elif ct is not None: - if ct.media_type.lower() == "application/json": - body = r.json() - - return HttpResponse(body, r.status_code, r.headers, ct) diff --git a/tests/protocols/http/core/__init__.py b/tests/protocols/http/core/__init__.py deleted file mode 100644 index 9c0a68e7..00000000 --- a/tests/protocols/http/core/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# SPDX-FileCopyrightText: 2025 Greenbone AG -# -# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/tests/protocols/http/core/test_api.py b/tests/protocols/http/core/test_api.py deleted file mode 100644 index 150d4be6..00000000 --- a/tests/protocols/http/core/test_api.py +++ /dev/null @@ -1,23 +0,0 @@ -# SPDX-FileCopyrightText: 2025 Greenbone AG -# -# SPDX-License-Identifier: GPL-3.0-or-later - -import unittest -from unittest.mock import MagicMock, patch - -from gvm.protocols.http.core._api import GvmHttpApi - - -class GvmHttpApiTestCase(unittest.TestCase): - # pylint: disable=protected-access - - @patch("gvm.protocols.http.core.connector.HttpApiConnector") - def test_basic_init(self, connector_mock: MagicMock): - api = GvmHttpApi(connector_mock) - self.assertEqual(connector_mock, api._connector) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector") - def test_init_with_key(self, connector_mock: MagicMock): - api = GvmHttpApi(connector_mock, api_key="my-api-key") - self.assertEqual(connector_mock, api._connector) - self.assertEqual("my-api-key", api._api_key) diff --git a/tests/protocols/http/core/test_connector.py b/tests/protocols/http/core/test_connector.py deleted file mode 100644 index 140ac347..00000000 --- a/tests/protocols/http/core/test_connector.py +++ /dev/null @@ -1,424 +0,0 @@ -# SPDX-FileCopyrightText: 2025 Greenbone AG -# -# SPDX-License-Identifier: GPL-3.0-or-later - -import json -import unittest -from http import HTTPStatus -from typing import Any, Optional, Union -from unittest.mock import MagicMock, Mock, patch - -import httpx -from httpx import HTTPError - -from gvm.protocols.http.core.connector import HttpApiConnector -from gvm.protocols.http.core.headers import ContentType - -TEST_JSON_HEADERS = { - "content-type": "application/json;charset=utf-8", - "x-example": "some-test-header", -} - -JSON_EXTRA_HEADERS = {"content-length": "26"} - -TEST_JSON_CONTENT_TYPE = ContentType( - media_type="application/json", - params={"charset": "utf-8"}, - charset="utf-8", -) - -TEST_EMPTY_CONTENT_TYPE = ContentType( - media_type="application/octet-stream", - params={}, - charset=None, -) - -TEST_JSON_RESPONSE_BODY = {"response_content": True} - -TEST_JSON_REQUEST_BODY = {"request_number": 5} - - -def new_mock_empty_response_func( - method: str, - status: Optional[Union[int, HTTPStatus]] = None, -) -> httpx.Response: - def response_func(request_url, *_args, **_kwargs): - response = httpx.Response( - request=httpx.Request(method, request_url), - content=b"", - status_code=( - int(HTTPStatus.NO_CONTENT) if status is None else int(status) - ), - ) - return response - - return response_func - - -def new_mock_json_response_func( - method: str, - content: Optional[Any] = None, - status: Optional[Union[int, HTTPStatus]] = None, -) -> httpx.Response: - def response_func(request_url, *_args, **_kwargs): - response = httpx.Response( - request=httpx.Request(method, request_url), - content=json.dumps(content).encode(), - status_code=int(HTTPStatus.OK) if status is None else int(status), - headers=TEST_JSON_HEADERS, - ) - return response - - return response_func - - -def new_mock_client(*, headers: Optional[dict] = None) -> Mock: - mock = Mock(spec=httpx.Client) - mock.headers = headers if headers is not None else {} - return mock - - -class HttpApiConnectorTestCase(unittest.TestCase): - # pylint: disable=protected-access - def assertHasHeaders(self, expected_headers, actual_headers): - self.assertEqual( - expected_headers | dict(actual_headers), actual_headers - ) - - def test_url_join(self): - self.assertEqual( - "http://localhost/foo/bar/baz", - HttpApiConnector.url_join("http://localhost/foo", "bar/baz"), - ) - self.assertEqual( - "http://localhost/foo/bar/baz", - HttpApiConnector.url_join("http://localhost/foo/", "bar/baz"), - ) - self.assertEqual( - "http://localhost/foo/bar/baz", - HttpApiConnector.url_join("http://localhost/foo", "./bar/baz"), - ) - self.assertEqual( - "http://localhost/foo/bar/baz", - HttpApiConnector.url_join("http://localhost/foo/", "./bar/baz"), - ) - self.assertEqual( - "http://localhost/bar/baz", - HttpApiConnector.url_join("http://localhost/foo", "../bar/baz"), - ) - self.assertEqual( - "http://localhost/bar/baz", - HttpApiConnector.url_join("http://localhost/foo", "../bar/baz"), - ) - - def test_new_session(self): - new_client = HttpApiConnector._new_client() - self.assertIsInstance(new_client, httpx.Client) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") - def test_basic_init(self, new_client_mock: MagicMock): - mock_client = new_client_mock.return_value = new_mock_client() - - connector = HttpApiConnector("http://localhost") - - self.assertEqual("http://localhost", connector.base_url) - new_client_mock.assert_called_once_with(None, None) - self.assertEqual({}, mock_client.headers) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") - def test_https_init(self, new_client_mock: MagicMock): - mock_client = new_client_mock.return_value = new_mock_client() - - connector = HttpApiConnector( - "https://localhost", - server_ca_path="foo.crt", - client_cert_paths="bar.key", - ) - - self.assertEqual("https://localhost", connector.base_url) - new_client_mock.assert_called_once_with("foo.crt", "bar.key") - self.assertEqual({}, mock_client.headers) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") - def test_update_headers(self, new_client_mock: MagicMock): - mock_client = new_client_mock.return_value = new_mock_client() - - connector = HttpApiConnector( - "http://localhost", - ) - connector.update_headers({"x-foo": "bar"}) - connector.update_headers({"x-baz": "123"}) - - self.assertEqual({"x-foo": "bar", "x-baz": "123"}, mock_client.headers) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") - def test_delete(self, new_client_mock: MagicMock): - mock_client = new_client_mock.return_value = new_mock_client() - - mock_client.delete.side_effect = new_mock_json_response_func( - "DELETE", TEST_JSON_RESPONSE_BODY - ) - connector = HttpApiConnector("https://localhost") - response = connector.delete( - "foo", params={"bar": "123"}, headers={"baz": "456"} - ) - - self.assertEqual(int(HTTPStatus.OK), response.status) - self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) - self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) - self.assertEqual( - TEST_JSON_HEADERS | JSON_EXTRA_HEADERS, response.headers - ) - - mock_client.delete.assert_called_once_with( - "https://localhost/foo", - params={"bar": "123"}, - headers={"baz": "456"}, - ) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") - def test_minimal_delete(self, new_client_mock: MagicMock): - mock_client = new_client_mock.return_value = new_mock_client() - - mock_client.delete.side_effect = new_mock_empty_response_func("DELETE") - connector = HttpApiConnector("https://localhost") - response = connector.delete("foo") - - self.assertEqual(int(HTTPStatus.NO_CONTENT), response.status) - self.assertEqual(None, response.body) - self.assertEqual(TEST_EMPTY_CONTENT_TYPE, response.content_type) - self.assertEqual({}, response.headers) - - mock_client.delete.assert_called_once_with( - "https://localhost/foo", params=None, headers=None - ) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") - def test_delete_raise_on_status(self, new_client_mock: MagicMock): - mock_client = new_client_mock.return_value = new_mock_client() - - mock_client.delete.side_effect = new_mock_empty_response_func( - "DELETE", HTTPStatus.INTERNAL_SERVER_ERROR - ) - connector = HttpApiConnector("https://localhost") - self.assertRaises(httpx.HTTPError, connector.delete, "foo") - - mock_client.delete.assert_called_once_with( - "https://localhost/foo", params=None, headers=None - ) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") - def test_delete_no_raise_on_status(self, new_client_mock: MagicMock): - mock_client = new_client_mock.return_value = new_mock_client() - - mock_client.delete.side_effect = new_mock_json_response_func( - "DELETE", - content=TEST_JSON_RESPONSE_BODY, - status=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - connector = HttpApiConnector("https://localhost") - response = connector.delete( - "foo", - params={"bar": "123"}, - headers={"baz": "456"}, - raise_for_status=False, - ) - - self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) - self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) - self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) - self.assertEqual( - TEST_JSON_HEADERS | JSON_EXTRA_HEADERS, response.headers - ) - - mock_client.delete.assert_called_once_with( - "https://localhost/foo", - params={"bar": "123"}, - headers={"baz": "456"}, - ) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") - def test_get(self, new_client_mock: MagicMock): - mock_client = new_client_mock.return_value = new_mock_client() - - mock_client.get.side_effect = new_mock_json_response_func( - "GET", TEST_JSON_RESPONSE_BODY - ) - connector = HttpApiConnector("https://localhost") - response = connector.get( - "foo", params={"bar": "123"}, headers={"baz": "456"} - ) - - self.assertEqual(int(HTTPStatus.OK), response.status) - self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) - self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) - self.assertEqual( - TEST_JSON_HEADERS | JSON_EXTRA_HEADERS, response.headers - ) - - mock_client.get.assert_called_once_with( - "https://localhost/foo", - params={"bar": "123"}, - headers={"baz": "456"}, - ) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") - def test_minimal_get(self, new_client_mock: MagicMock): - mock_client = new_client_mock.return_value = new_mock_client() - - mock_client.get.side_effect = new_mock_empty_response_func("GET") - connector = HttpApiConnector("https://localhost") - response = connector.get("foo") - - self.assertEqual(int(HTTPStatus.NO_CONTENT), response.status) - self.assertEqual(None, response.body) - self.assertEqual(TEST_EMPTY_CONTENT_TYPE, response.content_type) - self.assertEqual({}, response.headers) - - mock_client.get.assert_called_once_with( - "https://localhost/foo", params=None, headers=None - ) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") - def test_get_raise_on_status(self, new_client_mock: MagicMock): - mock_client = new_client_mock.return_value = new_mock_client() - - mock_client.get.side_effect = new_mock_empty_response_func( - "GET", HTTPStatus.INTERNAL_SERVER_ERROR - ) - connector = HttpApiConnector("https://localhost") - self.assertRaises(HTTPError, connector.get, "foo") - - mock_client.get.assert_called_once_with( - "https://localhost/foo", params=None, headers=None - ) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") - def test_get_no_raise_on_status(self, new_client_mock: MagicMock): - mock_client = new_client_mock.return_value = new_mock_client() - - mock_client.get.side_effect = new_mock_json_response_func( - "GET", - content=TEST_JSON_RESPONSE_BODY, - status=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - connector = HttpApiConnector("https://localhost") - response = connector.get( - "foo", - params={"bar": "123"}, - headers={"baz": "456"}, - raise_for_status=False, - ) - - self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) - self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) - self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) - self.assertEqual( - TEST_JSON_HEADERS | JSON_EXTRA_HEADERS, response.headers - ) - - mock_client.get.assert_called_once_with( - "https://localhost/foo", - params={"bar": "123"}, - headers={"baz": "456"}, - ) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") - def test_post_json(self, new_client_mock: MagicMock): - mock_client = new_client_mock.return_value = new_mock_client() - - mock_client.post.side_effect = new_mock_json_response_func( - "POST", TEST_JSON_RESPONSE_BODY - ) - connector = HttpApiConnector("https://localhost") - response = connector.post_json( - "foo", - json={"number": 5}, - params={"bar": "123"}, - headers={"baz": "456"}, - ) - - self.assertEqual(int(HTTPStatus.OK), response.status) - self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) - self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) - self.assertEqual( - TEST_JSON_HEADERS | JSON_EXTRA_HEADERS, response.headers - ) - - mock_client.post.assert_called_once_with( - "https://localhost/foo", - json={"number": 5}, - params={"bar": "123"}, - headers={"baz": "456"}, - ) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") - def test_minimal_post_json(self, new_client_mock: MagicMock): - mock_client = new_client_mock.return_value = new_mock_client() - - mock_client.post.side_effect = new_mock_empty_response_func("POST") - connector = HttpApiConnector("https://localhost") - response = connector.post_json("foo", TEST_JSON_REQUEST_BODY) - - self.assertEqual(int(HTTPStatus.NO_CONTENT), response.status) - self.assertEqual(None, response.body) - self.assertEqual(TEST_EMPTY_CONTENT_TYPE, response.content_type) - self.assertEqual({}, response.headers) - - mock_client.post.assert_called_once_with( - "https://localhost/foo", - json=TEST_JSON_REQUEST_BODY, - params=None, - headers=None, - ) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") - def test_post_json_raise_on_status(self, new_client_mock: MagicMock): - mock_client = new_client_mock.return_value = new_mock_client() - - mock_client.post.side_effect = new_mock_empty_response_func( - "POST", HTTPStatus.INTERNAL_SERVER_ERROR - ) - connector = HttpApiConnector("https://localhost") - self.assertRaises( - HTTPError, connector.post_json, "foo", json=TEST_JSON_REQUEST_BODY - ) - - mock_client.post.assert_called_once_with( - "https://localhost/foo", - json=TEST_JSON_REQUEST_BODY, - params=None, - headers=None, - ) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") - def test_post_json_no_raise_on_status(self, new_client_mock: MagicMock): - mock_client = new_client_mock.return_value = new_mock_client() - - mock_client.post.side_effect = new_mock_json_response_func( - "POST", - content=TEST_JSON_RESPONSE_BODY, - status=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - connector = HttpApiConnector("https://localhost") - response = connector.post_json( - "foo", - json=TEST_JSON_REQUEST_BODY, - params={"bar": "123"}, - headers={"baz": "456"}, - raise_for_status=False, - ) - - self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) - self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) - self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) - self.assertEqual( - TEST_JSON_HEADERS | JSON_EXTRA_HEADERS, response.headers - ) - - mock_client.post.assert_called_once_with( - "https://localhost/foo", - json=TEST_JSON_REQUEST_BODY, - params={"bar": "123"}, - headers={"baz": "456"}, - ) diff --git a/tests/protocols/http/core/test_headers.py b/tests/protocols/http/core/test_headers.py deleted file mode 100644 index 6e1581f0..00000000 --- a/tests/protocols/http/core/test_headers.py +++ /dev/null @@ -1,44 +0,0 @@ -# SPDX-FileCopyrightText: 2025 Greenbone AG -# -# SPDX-License-Identifier: GPL-3.0-or-later - -import unittest - -from gvm.protocols.http.core.headers import ContentType - - -class ContentTypeTestCase(unittest.TestCase): - - def test_from_empty_string(self): - ct = ContentType.from_string("") - self.assertEqual("application/octet-stream", ct.media_type) - self.assertEqual({}, ct.params) - self.assertEqual(None, ct.charset) - - def test_from_basic_string(self): - ct = ContentType.from_string("text/html") - self.assertEqual("text/html", ct.media_type) - self.assertEqual({}, ct.params) - self.assertEqual(None, ct.charset) - - def test_from_string_with_charset(self): - ct = ContentType.from_string("text/html; charset=utf-32 ") - self.assertEqual("text/html", ct.media_type) - self.assertEqual({"charset": "utf-32"}, ct.params) - self.assertEqual("utf-32", ct.charset) - - def test_from_string_with_param(self): - ct = ContentType.from_string( - "multipart/form-data; boundary===boundary==; charset=utf-32 " - ) - self.assertEqual("multipart/form-data", ct.media_type) - self.assertEqual( - {"boundary": "==boundary==", "charset": "utf-32"}, ct.params - ) - self.assertEqual("utf-32", ct.charset) - - def test_from_string_with_valueless_param(self): - ct = ContentType.from_string("text/html; x-foo") - self.assertEqual("text/html", ct.media_type) - self.assertEqual({"x-foo": True}, ct.params) - self.assertEqual(None, ct.charset) diff --git a/tests/protocols/http/core/test_response.py b/tests/protocols/http/core/test_response.py deleted file mode 100644 index 287a618d..00000000 --- a/tests/protocols/http/core/test_response.py +++ /dev/null @@ -1,65 +0,0 @@ -# SPDX-FileCopyrightText: 2025 Greenbone AG -# -# SPDX-License-Identifier: GPL-3.0-or-later -import json -import unittest -from http import HTTPStatus - -import requests as requests_lib - -from gvm.protocols.http.core.response import HttpResponse - - -class HttpResponseFromRequestsLibTestCase(unittest.TestCase): - def test_from_empty_response(self): - # pylint: disable=protected-access - requests_response = requests_lib.Response() - requests_response.status_code = int(HTTPStatus.OK) - requests_response._content = b"" - - response = HttpResponse.from_requests_lib(requests_response) - - self.assertIsNone(response.body) - self.assertEqual(int(HTTPStatus.OK), response.status) - self.assertEqual({}, response.headers) - - def test_from_plain_text_response(self): - # pylint: disable=protected-access - requests_response = requests_lib.Response() - requests_response.status_code = int(HTTPStatus.OK) - requests_response.headers.update({"content-type": "text/plain"}) - requests_response._content = b"ABCDEF" - - response = HttpResponse.from_requests_lib(requests_response) - - self.assertEqual(b"ABCDEF", response.body) - self.assertEqual(int(HTTPStatus.OK), response.status) - self.assertEqual({"content-type": "text/plain"}, response.headers) - - def test_from_json_response(self): - # pylint: disable=protected-access - test_content = {"foo": ["bar", 12345], "baz": True} - requests_response = requests_lib.Response() - requests_response.status_code = int(HTTPStatus.OK) - requests_response.headers.update({"content-type": "application/json"}) - requests_response._content = json.dumps(test_content).encode() - - response = HttpResponse.from_requests_lib(requests_response) - - self.assertEqual(test_content, response.body) - self.assertEqual(int(HTTPStatus.OK), response.status) - self.assertEqual({"content-type": "application/json"}, response.headers) - - def test_from_error_json_response(self): - # pylint: disable=protected-access - test_content = {"error": "Internal server error"} - requests_response = requests_lib.Response() - requests_response.status_code = int(HTTPStatus.INTERNAL_SERVER_ERROR) - requests_response.headers.update({"content-type": "application/json"}) - requests_response._content = json.dumps(test_content).encode() - - response = HttpResponse.from_requests_lib(requests_response) - - self.assertEqual(test_content, response.body) - self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) - self.assertEqual({"content-type": "application/json"}, response.headers) diff --git a/tests/protocols/http/openvasd/test_openvasd1.py b/tests/protocols/http/openvasd/test_openvasd1.py deleted file mode 100644 index 3b523a26..00000000 --- a/tests/protocols/http/openvasd/test_openvasd1.py +++ /dev/null @@ -1,350 +0,0 @@ -# SPDX-FileCopyrightText: 2025 Greenbone AG -# -# SPDX-License-Identifier: GPL-3.0-or-later - -import unittest -from http import HTTPStatus -from typing import Optional, Union -from unittest.mock import Mock, patch - -from gvm.errors import InvalidArgumentType -from gvm.protocols.http.core.headers import ContentType -from gvm.protocols.http.core.response import HttpResponse -from gvm.protocols.http.openvasd.openvasd1 import OpenvasdHttpApiV1 - - -def new_mock_empty_response( - status: Optional[Union[int, HTTPStatus]] = None, - headers: Optional[dict[str, str]] = None, -): - if status is None: - status = int(HTTPStatus.NO_CONTENT) - if headers is None: - headers = {} - content_type = ContentType.from_string(None) - return HttpResponse( - body=None, status=status, headers=headers, content_type=content_type - ) - - -class OpenvasdHttpApiV1TestCase(unittest.TestCase): - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_init(self, mock_connector: Mock): - api = OpenvasdHttpApiV1(mock_connector) - mock_connector.update_headers.assert_not_called() - self.assertIsNotNone(api) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_init_with_api_key(self, mock_connector: Mock): - api = OpenvasdHttpApiV1(mock_connector, api_key="my-API-key") - mock_connector.update_headers.assert_called_once_with( - {"X-API-KEY": "my-API-key"} - ) - self.assertIsNotNone(api) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_get_health_alive(self, mock_connector: Mock): - expected_response = new_mock_empty_response() - mock_connector.get.return_value = expected_response - api = OpenvasdHttpApiV1(mock_connector) - - response = api.get_health_alive() - mock_connector.get.assert_called_once_with( - "/health/alive", raise_for_status=False - ) - self.assertEqual(expected_response, response) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_get_health_ready(self, mock_connector: Mock): - expected_response = new_mock_empty_response() - mock_connector.get.return_value = expected_response - api = OpenvasdHttpApiV1(mock_connector) - - response = api.get_health_ready() - mock_connector.get.assert_called_once_with( - "/health/ready", raise_for_status=False - ) - self.assertEqual(expected_response, response) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_get_health_started(self, mock_connector: Mock): - expected_response = new_mock_empty_response() - mock_connector.get.return_value = expected_response - api = OpenvasdHttpApiV1(mock_connector) - - response = api.get_health_started() - mock_connector.get.assert_called_once_with( - "/health/started", raise_for_status=False - ) - self.assertEqual(expected_response, response) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_get_notus_os_list(self, mock_connector: Mock): - expected_response = new_mock_empty_response() - mock_connector.get.return_value = expected_response - api = OpenvasdHttpApiV1(mock_connector) - - response = api.get_notus_os_list() - mock_connector.get.assert_called_once_with( - "/notus", raise_for_status=False - ) - self.assertEqual(expected_response, response) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_run_notus_scan(self, mock_connector: Mock): - expected_response = new_mock_empty_response() - mock_connector.post_json.return_value = expected_response - api = OpenvasdHttpApiV1(mock_connector) - - response = api.run_notus_scan("Debian 11", ["foo-1.0", "bar-0.23"]) - mock_connector.post_json.assert_called_once_with( - "/notus/Debian%2011", - ["foo-1.0", "bar-0.23"], - raise_for_status=False, - ) - self.assertEqual(expected_response, response) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_get_scan_preferences(self, mock_connector: Mock): - expected_response = new_mock_empty_response() - mock_connector.get.return_value = expected_response - api = OpenvasdHttpApiV1(mock_connector) - - response = api.get_scan_preferences() - mock_connector.get.assert_called_once_with( - "/scans/preferences", raise_for_status=False - ) - self.assertEqual(expected_response, response) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_create_scan(self, mock_connector: Mock): - expected_response = new_mock_empty_response() - mock_connector.post_json.return_value = expected_response - api = OpenvasdHttpApiV1(mock_connector) - - # minimal scan - response = api.create_scan( - {"hosts": "somehost"}, - [{"oid": "some_vt"}, {"oid": "another_vt"}], - ) - mock_connector.post_json.assert_called_once_with( - "/scans", - { - "target": {"hosts": "somehost"}, - "vts": [{"oid": "some_vt"}, {"oid": "another_vt"}], - }, - raise_for_status=False, - ) - self.assertEqual(expected_response, response) - - # scan with all options - mock_connector.post_json.reset_mock() - response = api.create_scan( - {"hosts": "somehost"}, - [{"oid": "some_vt"}, {"oid": "another_vt"}], - {"my_scanner_param": "abc"}, - ) - mock_connector.post_json.assert_called_once_with( - "/scans", - { - "target": {"hosts": "somehost"}, - "vts": [{"oid": "some_vt"}, {"oid": "another_vt"}], - "scan_preferences": {"my_scanner_param": "abc"}, - }, - raise_for_status=False, - ) - self.assertEqual(expected_response, response) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_delete_scan(self, mock_connector: Mock): - expected_response = new_mock_empty_response() - mock_connector.delete.return_value = expected_response - api = OpenvasdHttpApiV1(mock_connector) - - response = api.delete_scan("foo bar") - mock_connector.delete.assert_called_once_with( - "/scans/foo%20bar", raise_for_status=False - ) - self.assertEqual(expected_response, response) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_get_scans(self, mock_connector: Mock): - expected_response = new_mock_empty_response() - mock_connector.get.return_value = expected_response - api = OpenvasdHttpApiV1(mock_connector) - - response = api.get_scans() - mock_connector.get.assert_called_once_with( - "/scans", raise_for_status=False - ) - self.assertEqual(expected_response, response) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_get_scan(self, mock_connector: Mock): - expected_response = new_mock_empty_response() - mock_connector.get.return_value = expected_response - api = OpenvasdHttpApiV1(mock_connector) - - response = api.get_scan("foo bar") - mock_connector.get.assert_called_once_with( - "/scans/foo%20bar", raise_for_status=False - ) - self.assertEqual(expected_response, response) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_get_scan_results(self, mock_connector: Mock): - expected_response = new_mock_empty_response() - mock_connector.get.return_value = expected_response - api = OpenvasdHttpApiV1(mock_connector) - - response = api.get_scan_results("foo bar") - mock_connector.get.assert_called_once_with( - "/scans/foo%20bar/results", params={}, raise_for_status=False - ) - self.assertEqual(expected_response, response) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_get_scan_results_with_ranges(self, mock_connector: Mock): - expected_response = new_mock_empty_response() - mock_connector.get.return_value = expected_response - api = OpenvasdHttpApiV1(mock_connector) - - # range start only - response = api.get_scan_results("foo bar", 12) - mock_connector.get.assert_called_once_with( - "/scans/foo%20bar/results", - params={"range": "12"}, - raise_for_status=False, - ) - self.assertEqual(expected_response, response) - - # range start and end - mock_connector.get.reset_mock() - response = api.get_scan_results("foo bar", 12, 34) - mock_connector.get.assert_called_once_with( - "/scans/foo%20bar/results", - params={"range": "12-34"}, - raise_for_status=False, - ) - self.assertEqual(expected_response, response) - - # range end only - mock_connector.get.reset_mock() - response = api.get_scan_results("foo bar", range_end=23) - mock_connector.get.assert_called_once_with( - "/scans/foo%20bar/results", - params={"range": "0-23"}, - raise_for_status=False, - ) - self.assertEqual(expected_response, response) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_get_scan_results_with_invalid_ranges(self, mock_connector: Mock): - expected_response = new_mock_empty_response() - mock_connector.get.return_value = expected_response - api = OpenvasdHttpApiV1(mock_connector) - - # range start - self.assertRaises( - InvalidArgumentType, api.get_scan_results, "foo bar", "invalid" - ) - - # range start and end - self.assertRaises( - InvalidArgumentType, api.get_scan_results, "foo bar", 12, "invalid" - ) - - # range end only - self.assertRaises( - InvalidArgumentType, - api.get_scan_results, - "foo bar", - range_end="invalid", - ) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_get_scan_result(self, mock_connector: Mock): - expected_response = new_mock_empty_response() - mock_connector.get.return_value = expected_response - api = OpenvasdHttpApiV1(mock_connector) - - response = api.get_scan_result("foo bar", "baz qux") - mock_connector.get.assert_called_once_with( - "/scans/foo%20bar/results/baz%20qux", raise_for_status=False - ) - self.assertEqual(expected_response, response) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_get_scan_status(self, mock_connector: Mock): - expected_response = new_mock_empty_response() - mock_connector.get.return_value = expected_response - api = OpenvasdHttpApiV1(mock_connector) - - response = api.get_scan_status("foo bar") - mock_connector.get.assert_called_once_with( - "/scans/foo%20bar/status", raise_for_status=False - ) - self.assertEqual(expected_response, response) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_run_scan_action(self, mock_connector: Mock): - expected_response = new_mock_empty_response() - mock_connector.post_json.return_value = expected_response - api = OpenvasdHttpApiV1(mock_connector) - - response = api.run_scan_action("foo bar", "do-something") - mock_connector.post_json.assert_called_once_with( - "/scans/foo%20bar", - {"action": "do-something"}, - raise_for_status=False, - ) - self.assertEqual(expected_response, response) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_start_scan(self, mock_connector: Mock): - expected_response = new_mock_empty_response() - mock_connector.post_json.return_value = expected_response - api = OpenvasdHttpApiV1(mock_connector) - - response = api.start_scan("foo bar") - mock_connector.post_json.assert_called_once_with( - "/scans/foo%20bar", {"action": "start"}, raise_for_status=False - ) - self.assertEqual(expected_response, response) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_stop_scan(self, mock_connector: Mock): - expected_response = new_mock_empty_response() - mock_connector.post_json.return_value = expected_response - api = OpenvasdHttpApiV1(mock_connector) - - response = api.stop_scan("foo bar") - mock_connector.post_json.assert_called_once_with( - "/scans/foo%20bar", {"action": "stop"}, raise_for_status=False - ) - self.assertEqual(expected_response, response) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_get_vts(self, mock_connector: Mock): - expected_response = new_mock_empty_response() - mock_connector.get.return_value = expected_response - api = OpenvasdHttpApiV1(mock_connector) - - response = api.get_vts() - mock_connector.get.assert_called_once_with( - "/vts", raise_for_status=False - ) - self.assertEqual(expected_response, response) - - @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) - def test_get_vt(self, mock_connector: Mock): - expected_response = new_mock_empty_response() - mock_connector.get.return_value = expected_response - api = OpenvasdHttpApiV1(mock_connector) - - response = api.get_vt("foo bar") - mock_connector.get.assert_called_once_with( - "/vts/foo%20bar", raise_for_status=False - ) - self.assertEqual(expected_response, response) From f586770420f040fcefd3f6c117bc58d176c36188 Mon Sep 17 00:00:00 2001 From: ozgen Date: Fri, 20 Jun 2025 13:23:51 +0200 Subject: [PATCH 20/34] Add http2 packages to poetry files. --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6670a546..4a999376 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "alabaster" @@ -716,7 +716,7 @@ version = "4.2.0" description = "Pure-Python HTTP/2 protocol implementation" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "h2-4.2.0-py3-none-any.whl", hash = "sha256:479a53ad425bb29af087f3458a61d30780bc818e4ebcf01f0b536ba916462ed0"}, {file = "h2-4.2.0.tar.gz", hash = "sha256:c8a52129695e88b1a0578d8d2cc6842bbd79128ac685463b887ee278126ad01f"}, @@ -732,7 +732,7 @@ version = "4.1.0" description = "Pure-Python HPACK header encoding" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496"}, {file = "hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca"}, @@ -792,7 +792,7 @@ version = "6.1.0" description = "Pure-Python HTTP/2 framing" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"}, {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"}, diff --git a/pyproject.toml b/pyproject.toml index e5cda5c3..a6b59c9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ packages = [{ include = "gvm" }, { include = "tests", format = "sdist" }] python = "^3.9.2" paramiko = ">=2.7.1" lxml = ">=4.5.0" -httpx = "^0.28.1" +httpx = {extras = ["http2"], version = "^0.28.1"} [tool.poetry.group.dev.dependencies] coverage = ">=7.2" From 60927924278615378d1bb8ca2e1810a7ec28be19 Mon Sep 17 00:00:00 2001 From: ozgen Date: Mon, 23 Jun 2025 07:52:43 +0200 Subject: [PATCH 21/34] Remove old implementation of openvasd1 --- gvm/protocols/http/openvasd/openvasd1.py | 423 ----------------------- 1 file changed, 423 deletions(-) delete mode 100644 gvm/protocols/http/openvasd/openvasd1.py diff --git a/gvm/protocols/http/openvasd/openvasd1.py b/gvm/protocols/http/openvasd/openvasd1.py deleted file mode 100644 index 14dd22bb..00000000 --- a/gvm/protocols/http/openvasd/openvasd1.py +++ /dev/null @@ -1,423 +0,0 @@ -# SPDX-FileCopyrightText: 2025 Greenbone AG -# -# SPDX-License-Identifier: GPL-3.0-or-later - -""" -openvasd HTTP API version 1 -""" - -import urllib.parse -from typing import Any, Optional, Union - -from gvm.errors import InvalidArgumentType -from gvm.protocols.http.core._api import GvmHttpApi -from gvm.protocols.http.core.connector import HttpApiConnector -from gvm.protocols.http.core.response import HttpResponse - - -class OpenvasdHttpApiV1(GvmHttpApi): - """ - Class for sending requests to a version 1 openvasd API. - """ - - def __init__( - self, - connector: HttpApiConnector, - *, - api_key: Optional[str] = None, - ): - """ - Create a new openvasd HTTP API instance. - - Args: - connector: The connector handling the HTTP(S) connection - api_key: Optional API key for authentication - """ - super().__init__(connector, api_key=api_key) - if api_key: - connector.update_headers({"X-API-KEY": api_key}) - - def get_health_alive( - self, *, raise_for_status: bool = False - ) -> HttpResponse: - """ - Gets the "alive" health status of the scanner. - - Args: - raise_for_status: Whether to raise an error if scanner responded with a - non-success HTTP status code. - - Return: - The HTTP response. See GET /health/alive in the openvasd API documentation. - """ - return self._connector.get( - "/health/alive", raise_for_status=raise_for_status - ) - - def get_health_ready( - self, *, raise_for_status: bool = False - ) -> HttpResponse: - """ - Gets the "ready" health status of the scanner. - - Args: - raise_for_status: Whether to raise an error if scanner responded with a - non-success HTTP status code. - - Return: - The HTTP response. See GET /health/ready in the openvasd API documentation. - """ - return self._connector.get( - "/health/ready", raise_for_status=raise_for_status - ) - - def get_health_started( - self, *, raise_for_status: bool = False - ) -> HttpResponse: - """ - Gets the "started" health status of the scanner. - - Args: - raise_for_status: Whether to raise an error if scanner responded with a - non-success HTTP status code. - - Return: - The HTTP response. See GET /health/started in the openvasd API documentation. - """ - return self._connector.get( - "/health/started", raise_for_status=raise_for_status - ) - - def get_notus_os_list( - self, *, raise_for_status: bool = False - ) -> HttpResponse: - """ - Gets the list of operating systems available in Notus. - - Args: - raise_for_status: Whether to raise an error if scanner responded with a - non-success HTTP status code. - - Return: - The HTTP response. See GET /notus in the openvasd API documentation. - """ - return self._connector.get("/notus", raise_for_status=raise_for_status) - - def run_notus_scan( - self, - os: str, - package_list: list[str], - *, - raise_for_status: bool = False, - ) -> HttpResponse: - """ - Gets the Notus results for a given operating system and list of packages. - - Args: - os: Name of the operating system as returned in the list returned by - get_notus_os_products. - package_list: List of package names to check. - raise_for_status: Whether to raise an error if scanner responded with a - non-success HTTP status code. - - Return: - The HTTP response. See POST /notus/{os} in the openvasd API documentation. - """ - quoted_os = urllib.parse.quote(os) - return self._connector.post_json( - f"/notus/{quoted_os}", - package_list, - raise_for_status=raise_for_status, - ) - - def get_scan_preferences( - self, *, raise_for_status: bool = False - ) -> HttpResponse: - """ - Gets the list of available scan preferences. - - Args: - raise_for_status: Whether to raise an error if scanner responded with a - non-success HTTP status code. - - Return: - The HTTP response. See POST /scans/preferences in the openvasd API documentation. - """ - return self._connector.get( - "/scans/preferences", raise_for_status=raise_for_status - ) - - def create_scan( - self, - target: dict[str, Any], - vt_selection: list[dict[str, Any]], - scanner_params: Optional[dict[str, Any]] = None, - *, - raise_for_status: bool = False, - ) -> HttpResponse: - """ - Creates a new scan without starting it. - - See POST /scans in the openvasd API documentation for the expected format of the parameters. - - Args: - target: The target definition for the scan. - vt_selection: The VT selection for the scan, including VT preferences. - scanner_params: The optional scanner parameters. - raise_for_status: Whether to raise an error if scanner responded with a - non-success HTTP status code. - - Return: - The HTTP response. See POST /scans in the openvasd API documentation. - """ - request_json: dict = { - "target": target, - "vts": vt_selection, - } - if scanner_params: - request_json["scan_preferences"] = scanner_params - return self._connector.post_json( - "/scans", request_json, raise_for_status=raise_for_status - ) - - def delete_scan( - self, scan_id: str, *, raise_for_status: bool = False - ) -> HttpResponse: - """ - Deletes a scan with the given id. - - Args: - scan_id: The id of the scan to perform the action on. - raise_for_status: Whether to raise an error if scanner responded with a - non-success HTTP status code. - - Return: - The HTTP response. See DELETE /scans/{id} in the openvasd API documentation. - """ - quoted_scan_id = urllib.parse.quote(scan_id) - return self._connector.delete( - f"/scans/{quoted_scan_id}", raise_for_status=raise_for_status - ) - - def get_scans(self, *, raise_for_status: bool = False) -> HttpResponse: - """ - Gets the list of available scans. - - Args: - raise_for_status: Whether to raise an error if scanner responded with a - non-success HTTP status code. - - Return: - The HTTP response. See GET /scans in the openvasd API documentation. - """ - return self._connector.get("/scans", raise_for_status=raise_for_status) - - def get_scan( - self, scan_id: str, *, raise_for_status: bool = False - ) -> HttpResponse: - """ - Gets a scan with the given id. - - Args: - scan_id: The id of the scan to get. - raise_for_status: Whether to raise an error if scanner responded with a - non-success HTTP status code. - - Return: - The HTTP response. See GET /scans/{id} in the openvasd API documentation. - """ - quoted_scan_id = urllib.parse.quote(scan_id) - return self._connector.get( - f"/scans/{quoted_scan_id}", raise_for_status=raise_for_status - ) - - def get_scan_results( - self, - scan_id: str, - range_start: Optional[int] = None, - range_end: Optional[int] = None, - *, - raise_for_status: bool = False, - ) -> HttpResponse: - """ - Gets results of a scan with the given id. - - Args: - scan_id: The id of the scan to get the results of. - range_start: Optional index of the first result to get. - range_end: Optional index of the last result to get. - raise_for_status: Whether to raise an error if scanner responded with a - non-success HTTP status code. - - Return: - The HTTP response. See GET /scans/{id}/results in the openvasd API documentation. - """ - quoted_scan_id = urllib.parse.quote(scan_id) - params = {} - if range_start is not None: - if not isinstance(range_start, int): - raise InvalidArgumentType( - argument="range_start", - function=self.get_scan_results.__name__, - ) - - if range_end is not None: - if not isinstance(range_end, int): - raise InvalidArgumentType( - argument="range_end", - function=self.get_scan_results.__name__, - ) - params["range"] = f"{range_start}-{range_end}" - else: - params["range"] = str(range_start) - else: - if range_end is not None: - if not isinstance(range_end, int): - raise InvalidArgumentType( - argument="range_end", - function=self.get_scan_results.__name__, - ) - params["range"] = f"0-{range_end}" - - return self._connector.get( - f"/scans/{quoted_scan_id}/results", - params=params, - raise_for_status=raise_for_status, - ) - - def get_scan_result( - self, - scan_id: str, - result_id: Union[str, int], - *, - raise_for_status: bool = False, - ) -> HttpResponse: - """ - Gets a single result of a scan with the given id. - - Args: - scan_id: The id of the scan to get the results of. - result_id: The id of the result to get. - raise_for_status: Whether to raise an error if scanner responded with a - non-success HTTP status code. - - Return: - The HTTP response. See GET /scans/{id}/{rid} in the openvasd API documentation. - """ - quoted_scan_id = urllib.parse.quote(scan_id) - quoted_result_id = urllib.parse.quote(str(result_id)) - - return self._connector.get( - f"/scans/{quoted_scan_id}/results/{quoted_result_id}", - raise_for_status=raise_for_status, - ) - - def get_scan_status( - self, scan_id: str, *, raise_for_status: bool = False - ) -> HttpResponse: - """ - Gets a scan with the given id. - - Args: - scan_id: The id of the scan to get the status of. - raise_for_status: Whether to raise an error if scanner responded with a - non-success HTTP status code. - - Return: - The HTTP response. See GET /scans/{id}/{rid} in the openvasd API documentation. - """ - quoted_scan_id = urllib.parse.quote(scan_id) - return self._connector.get( - f"/scans/{quoted_scan_id}/status", raise_for_status=raise_for_status - ) - - def run_scan_action( - self, scan_id: str, scan_action: str, *, raise_for_status: bool = False - ) -> HttpResponse: - """ - Performs an action like starting or stopping on a scan with the given id. - - Args: - scan_id: The id of the scan to perform the action on. - scan_action: The action to perform. - raise_for_status: Whether to raise an error if scanner responded with a - non-success HTTP status code. - - Return: - The HTTP response. See POST /scans/{id} in the openvasd API documentation. - """ - quoted_scan_id = urllib.parse.quote(scan_id) - action_json = {"action": scan_action} - return self._connector.post_json( - f"/scans/{quoted_scan_id}", - action_json, - raise_for_status=raise_for_status, - ) - - def start_scan( - self, scan_id: str, *, raise_for_status: bool = False - ) -> HttpResponse: - """ - Starts a scan with the given id. - - Args: - scan_id: The id of the scan to perform the action on. - raise_for_status: Whether to raise an error if scanner responded with a - non-success HTTP status code. - - Return: - The HTTP response. See POST /scans/{id} in the openvasd API documentation. - """ - return self.run_scan_action( - scan_id, "start", raise_for_status=raise_for_status - ) - - def stop_scan( - self, scan_id: str, *, raise_for_status: bool = False - ) -> HttpResponse: - """ - Stops a scan with the given id. - - Args: - scan_id: The id of the scan to perform the action on. - raise_for_status: Whether to raise an error if scanner responded with a - non-success HTTP status code. - - Return: - The HTTP response. See POST /scans/{id} in the openvasd API documentation. - """ - return self.run_scan_action( - scan_id, "stop", raise_for_status=raise_for_status - ) - - def get_vts(self, *, raise_for_status: bool = False) -> HttpResponse: - """ - Gets a list of available vulnerability tests (VTs) on the scanner. - - Args: - raise_for_status: Whether to raise an error if scanner responded with a - non-success HTTP status code. - - Return: - The HTTP response. See GET /vts in the openvasd API documentation. - """ - return self._connector.get("/vts", raise_for_status=raise_for_status) - - def get_vt( - self, oid: str, *, raise_for_status: bool = False - ) -> HttpResponse: - """ - Gets the details of a vulnerability test (VT). - - Args: - oid: OID of the VT to get. - raise_for_status: Whether to raise an error if scanner responded with a - non-success HTTP status code. - - Return: - The HTTP response. See DELETE /scans/{id} in the openvasd API documentation. - """ - quoted_oid = urllib.parse.quote(oid) - return self._connector.get( - f"/vts/{quoted_oid}", raise_for_status=raise_for_status - ) From 3a7eab728bdb7d7422a50571cd1c5b195581cacf Mon Sep 17 00:00:00 2001 From: ozgen Date: Mon, 23 Jun 2025 15:29:17 +0200 Subject: [PATCH 22/34] Add: Add new HTTP API structure and introduce modular sub-APIs --- gvm/protocols/http/openvasd/__init__.py | 8 +- gvm/protocols/http/openvasd/_client.py | 81 ++++ gvm/protocols/http/openvasd/health.py | 101 ++++ gvm/protocols/http/openvasd/metadata.py | 105 +++++ gvm/protocols/http/openvasd/notus.py | 79 ++++ gvm/protocols/http/openvasd/openvasdv1.py | 68 +++ gvm/protocols/http/openvasd/scans.py | 336 ++++++++++++++ gvm/protocols/http/openvasd/vts.py | 83 ++++ tests/protocols/http/openvasd/test_client.py | 77 ++++ tests/protocols/http/openvasd/test_health.py | 97 ++++ .../protocols/http/openvasd/test_metadata.py | 102 ++++ tests/protocols/http/openvasd/test_notus.py | 94 ++++ .../protocols/http/openvasd/test_openvasd1.py | 41 ++ tests/protocols/http/openvasd/test_scans.py | 436 ++++++++++++++++++ tests/protocols/http/openvasd/test_vts.py | 89 ++++ 15 files changed, 1795 insertions(+), 2 deletions(-) create mode 100644 gvm/protocols/http/openvasd/_client.py create mode 100644 gvm/protocols/http/openvasd/health.py create mode 100644 gvm/protocols/http/openvasd/metadata.py create mode 100644 gvm/protocols/http/openvasd/notus.py create mode 100644 gvm/protocols/http/openvasd/openvasdv1.py create mode 100644 gvm/protocols/http/openvasd/scans.py create mode 100644 gvm/protocols/http/openvasd/vts.py create mode 100644 tests/protocols/http/openvasd/test_client.py create mode 100644 tests/protocols/http/openvasd/test_health.py create mode 100644 tests/protocols/http/openvasd/test_metadata.py create mode 100644 tests/protocols/http/openvasd/test_notus.py create mode 100644 tests/protocols/http/openvasd/test_openvasd1.py create mode 100644 tests/protocols/http/openvasd/test_scans.py create mode 100644 tests/protocols/http/openvasd/test_vts.py diff --git a/gvm/protocols/http/openvasd/__init__.py b/gvm/protocols/http/openvasd/__init__.py index 251ddb41..0c738276 100644 --- a/gvm/protocols/http/openvasd/__init__.py +++ b/gvm/protocols/http/openvasd/__init__.py @@ -3,7 +3,11 @@ # SPDX-License-Identifier: GPL-3.0-or-later """ -Package for sending openvasd and handling the responses of HTTP API requests. +Package for sending requests to openvasd and handling HTTP API responses. -* :class:`OpenvasdHttpApiV1` - openvasd version 1 +Modules: +- :class:`OpenvasdHttpApiV1` – Main class for communicating with OpenVASD API v1. + +Usage: + from gvm.protocols.http.openvasd import OpenvasdHttpApiV1 """ diff --git a/gvm/protocols/http/openvasd/_client.py b/gvm/protocols/http/openvasd/_client.py new file mode 100644 index 00000000..886aa69f --- /dev/null +++ b/gvm/protocols/http/openvasd/_client.py @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +""" +Client wrapper for initializing a connection to the openvasd HTTP API using optional mTLS authentication. +""" + +import ssl +from typing import Optional, Tuple, Union + +from httpx import Client + + +class OpenvasdClient: + """ + The client wrapper around `httpx.Client` configured for mTLS-secured access or API KEY + to an openvasd HTTP API instance. + """ + + def __init__( + self, + host_name: str, + *, + api_key: Optional[str] = None, + server_ca_path: Optional[str] = None, + client_cert_paths: Optional[Union[str, Tuple[str, str]]] = None, + port: int = 3000, + ): + """ + Initialize the OpenVASD HTTP client with optional mTLS and API key. + + Args: + host_name: Hostname or IP of the OpenVASD server (e.g., "localhost"). + api_key: Optional API key used for authentication via HTTP headers. + server_ca_path: Path to the server's CA certificate (for verifying the server). + client_cert_paths: Path to the client certificate (str) or a tuple of + (cert_path, key_path) for mTLS authentication. + port: The port to connect to (default: 3000). + + Behavior: + - If both `server_ca_path` and `client_cert_paths` are set, an mTLS connection + is established using an SSLContext. + - If not, `verify` is set to False (insecure), and HTTP is used instead of HTTPS. + HTTP connection needs api_key for authorization. + """ + headers = {} + + context: Optional[ssl.SSLContext] = None + + # Prepare mTLS SSL context if needed + if client_cert_paths and server_ca_path: + context = ssl.create_default_context( + ssl.Purpose.SERVER_AUTH, cafile=server_ca_path + ) + if isinstance(client_cert_paths, tuple): + context.load_cert_chain( + certfile=client_cert_paths[0], keyfile=client_cert_paths[1] + ) + else: + context.load_cert_chain(certfile=client_cert_paths) + + context.check_hostname = False + context.verify_mode = ssl.CERT_REQUIRED + + # Set verify based on context presence + verify: Union[bool, ssl.SSLContext] = context if context else False + + if api_key: + headers["X-API-KEY"] = api_key + + protocol = "https" if context else "http" + base_url = f"{protocol}://{host_name}:{port}" + + self.client = Client( + base_url=base_url, + headers=headers, + verify=verify, + http2=True, + timeout=10.0, + ) diff --git a/gvm/protocols/http/openvasd/health.py b/gvm/protocols/http/openvasd/health.py new file mode 100644 index 00000000..231835dd --- /dev/null +++ b/gvm/protocols/http/openvasd/health.py @@ -0,0 +1,101 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +""" +API wrapper for accessing the /health endpoints of the openvasd HTTP API. +""" + +import httpx + + +class HealthAPI: + """ + Provides access to the openvasd /health endpoints, which expose the + operational state of the scanner. + + All methods return the HTTP status code of the response and raise an exception + if the server returns an error response (4xx or 5xx). + """ + + def __init__(self, client: httpx.Client): + """ + Create a new HealthAPI instance. + + Args: + client: An initialized `httpx.Client` configured for communicating + with the openvasd server. + """ + self._client = client + + def get_alive(self, safe: bool = False) -> int: + """ + Check if the scanner process is alive. + + Args: + safe: If True, suppress exceptions and return structured error responses. + + Returns: + HTTP status code (e.g., 200 if alive). + + Raises: + httpx.HTTPStatusError: If the server response indicates failure and safe is False. + + See: GET /health/alive in the openvasd API documentation. + """ + try: + response = self._client.get("/health/alive") + response.raise_for_status() + return response.status_code + except httpx.HTTPStatusError as e: + if safe: + return e.response.status_code + raise + + def get_ready(self, safe: bool = False) -> int: + """ + Check if the scanner is ready to accept requests (e.g., feed loaded). + + Args: + safe: If True, suppress exceptions and return structured error responses. + + Returns: + HTTP status code (e.g., 200 if ready). + + Raises: + httpx.HTTPStatusError: If the server response indicates failure and safe is False. + + See: GET /health/ready in the openvasd API documentation. + """ + try: + response = self._client.get("/health/ready") + response.raise_for_status() + return response.status_code + except httpx.HTTPStatusError as e: + if safe: + return e.response.status_code + raise + + def get_started(self, safe: bool = False) -> int: + """ + Check if the scanner has fully started. + + Args: + safe: If True, suppress exceptions and return structured error responses. + + Returns: + HTTP status code (e.g., 200 if started). + + Raises: + httpx.HTTPStatusError: If the server response indicates failure and safe is False. + + See: GET /health/started in the openvasd API documentation. + """ + try: + response = self._client.get("/health/started") + response.raise_for_status() + return response.status_code + except httpx.HTTPStatusError as e: + if safe: + return e.response.status_code + raise diff --git a/gvm/protocols/http/openvasd/metadata.py b/gvm/protocols/http/openvasd/metadata.py new file mode 100644 index 00000000..a48c07aa --- /dev/null +++ b/gvm/protocols/http/openvasd/metadata.py @@ -0,0 +1,105 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +""" +API wrapper for retrieving metadata from the openvasd HTTP API using HEAD requests. +""" + +import httpx + + +class MetadataAPI: + """ + Provides access to metadata endpoints exposed by the openvasd server + using lightweight HTTP HEAD requests. + + These endpoints return useful information in HTTP headers such as: + - API version + - Feed version + - Authentication type + + If the scanner is protected and the request is unauthorized, a 401 response + is handled gracefully. + """ + + def __init__(self, client: httpx.Client): + """ + Initialize a MetadataAPI instance. + + Args: + client: An `httpx.Client` configured to communicate with the openvasd server. + """ + self._client = client + + def get(self, safe: bool = False) -> dict: + """ + Perform a HEAD request to `/` to retrieve top-level API metadata. + + Args: + safe: If True, suppress exceptions and return structured error responses. + + Returns: + A dictionary containing: + + - "api-version" + - "feed-version" + - "authentication" + + Or if safe=True and error occurs: + + - {"error": str, "status_code": int} + + Raises: + httpx.HTTPStatusError: For non-401 HTTP errors if safe=False. + + See: HEAD / in the openvasd API documentation. + """ + try: + response = self._client.head("/") + response.raise_for_status() + return { + "api-version": response.headers.get("api-version"), + "feed-version": response.headers.get("feed-version"), + "authentication": response.headers.get("authentication"), + } + except httpx.HTTPStatusError as e: + if safe: + return {"error": str(e), "status_code": e.response.status_code} + raise + + def get_scans(self, safe: bool = False) -> dict: + """ + Perform a HEAD request to `/scans` to retrieve scan endpoint metadata. + + Args: + safe: If True, suppress exceptions and return structured error responses. + + Returns: + A dictionary containing: + + - "api-version" + - "feed-version" + - "authentication" + + Or if safe=True and error occurs: + + - {"error": str, "status_code": int} + + Raises: + httpx.HTTPStatusError: For non-401 HTTP errors if safe=False. + + See: HEAD /scans in the openvasd API documentation. + """ + try: + response = self._client.head("/scans") + response.raise_for_status() + return { + "api-version": response.headers.get("api-version"), + "feed-version": response.headers.get("feed-version"), + "authentication": response.headers.get("authentication"), + } + except httpx.HTTPStatusError as e: + if safe: + return {"error": str(e), "status_code": e.response.status_code} + raise diff --git a/gvm/protocols/http/openvasd/notus.py b/gvm/protocols/http/openvasd/notus.py new file mode 100644 index 00000000..098b5709 --- /dev/null +++ b/gvm/protocols/http/openvasd/notus.py @@ -0,0 +1,79 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +""" +API wrapper for interacting with the Notus component of the openvasd HTTP API. +""" + +import urllib.parse +from typing import List + +import httpx + + +class NotusAPI: + """ + Provides access to the Notus-related endpoints of the openvasd HTTP API. + + This includes retrieving supported operating systems and triggering + package-based vulnerability scans for a specific OS. + """ + + def __init__(self, client: httpx.Client): + """ + Initialize a NotusAPI instance. + + Args: + client: An `httpx.Client` configured to communicate with the openvasd server. + """ + self._client = client + + def get_os_list(self, safe: bool = False) -> httpx.Response: + """ + Retrieve the list of supported operating systems from the Notus service. + + Args: + safe: If True, return error info on failure instead of raising. + + Returns: + The full `httpx.Response` on success. + + See: GET /notus in the openvasd API documentation. + """ + try: + response = self._client.get("/notus") + response.raise_for_status() + return response + except httpx.HTTPStatusError as e: + if safe: + return e.response + raise + + def run_scan( + self, os: str, package_list: List[str], safe: bool = False + ) -> httpx.Response: + """ + Trigger a Notus scan for a given OS and list of packages. + + Args: + os: Operating system name (e.g., "debian", "alpine"). + package_list: List of package names to evaluate for vulnerabilities. + safe: If True, return error info on failure instead of raising. + + Returns: + The full `httpx.Response` on success. + + See: POST /notus/{os} in the openvasd API documentation. + """ + quoted_os = urllib.parse.quote(os) + try: + response = self._client.post( + f"/notus/{quoted_os}", json=package_list + ) + response.raise_for_status() + return response + except httpx.HTTPStatusError as e: + if safe: + return e.response + raise diff --git a/gvm/protocols/http/openvasd/openvasdv1.py b/gvm/protocols/http/openvasd/openvasdv1.py new file mode 100644 index 00000000..981fd2fa --- /dev/null +++ b/gvm/protocols/http/openvasd/openvasdv1.py @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +""" +openvasd HTTP API version 1 + +High-level API interface for interacting with openvasd HTTP services via +logical modules (health, metadata, scans, etc.). +""" + +from typing import Optional, Tuple, Union + +from ._client import OpenvasdClient +from .health import HealthAPI +from .metadata import MetadataAPI +from .notus import NotusAPI +from .scans import ScansAPI +from .vts import VtsAPI + + +class OpenvasdHttpApiV1: + """ + High-level interface for accessing openvasd HTTP API v1 endpoints. + + This class encapsulates modular sub-APIs (health, metadata, notus, scans, vts) + and wires them to a shared `httpx.Client` configured for secure access. + + Each sub-API provides methods for interacting with a specific openvasd domain. + """ + + def __init__( + self, + host_name: str, + port: int = 3000, + *, + api_key: Optional[str] = None, + server_ca_path: Optional[str] = None, + client_cert_paths: Optional[Union[str, Tuple[str, str]]] = None, + ): + """ + Initialize the OpenvasdHttpApiV1 entry point. + + Args: + host_name: Hostname or IP of the openvasd server (e.g., "localhost"). + port: Port of the openvasd service (default: 3000). + api_key: Optional API key to be used for authentication. + server_ca_path: Path to the server CA certificate (for HTTPS/mTLS). + client_cert_paths: Path to client certificate or (cert, key) tuple for mTLS. + + Behavior: + - Sets up an underlying `httpx.Client` using `OpenvasdClient`. + - Initializes sub-API modules with the shared client instance. + """ + self._client = OpenvasdClient( + host_name=host_name, + port=port, + api_key=api_key, + server_ca_path=server_ca_path, + client_cert_paths=client_cert_paths, + ).client + + # Sub-API modules + self.health = HealthAPI(self._client) + self.metadata = MetadataAPI(self._client) + self.notus = NotusAPI(self._client) + self.scans = ScansAPI(self._client) + self.vts = VtsAPI(self._client) diff --git a/gvm/protocols/http/openvasd/scans.py b/gvm/protocols/http/openvasd/scans.py new file mode 100644 index 00000000..67d363e1 --- /dev/null +++ b/gvm/protocols/http/openvasd/scans.py @@ -0,0 +1,336 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +""" +API wrapper for interacting with the /scans endpoints of the openvasd HTTP API. +""" + +import urllib.parse +from enum import Enum +from typing import Any, Optional, Union + +import httpx + +from gvm.errors import InvalidArgumentType + + +class ScanAction(str, Enum): + """ + Enumeration of valid scan actions supported by the openvasd API. + + This enum defines the allowed actions that can be performed on a scan. + Using an enum helps ensure type safety and prevents invalid action strings + from being passed to the API. + + Values: + START: Initiates the scan execution. + STOP: Terminates an ongoing scan. + """ + + START = "start" # Start the scan + STOP = "stop" # Stop the scan + + +class ScansAPI: + """ + Provides access to scan-related operations in the openvasd HTTP API. + + Includes methods for creating, starting, stopping, and retrieving scan details + and results. + """ + + def __init__(self, client: httpx.Client): + """ + Initialize a ScansAPI instance. + + Args: + client: An `httpx.Client` instance configured to talk to the openvasd server. + """ + self._client = client + + def create( + self, + target: dict[str, Any], + vt_selection: list[dict[str, Any]], + scanner_params: Optional[dict[str, Any]] = None, + safe: bool = False, + ) -> httpx.Response: + """ + Create a new scan with the specified target and VT selection. + + Args: + target: Dictionary describing the scan target (e.g., host and port). + vt_selection: List of dictionaries specifying which VTs to run. + scanner_params: Optional dictionary of scan preferences. + safe: If True, suppress exceptions and return structured error responses. + + Returns: + The full HTTP response of the POST /scans request. + + Raises: + httpx.HTTPStatusError: If the server responds with an error status. + + See: POST /scans in the openvasd API documentation. + """ + request_json = {"target": target, "vts": vt_selection} + if scanner_params: + request_json["scan_preferences"] = scanner_params + + try: + response = self._client.post("/scans", json=request_json) + response.raise_for_status() + return response + except httpx.HTTPStatusError as e: + if safe: + return e.response + raise + + def delete(self, scan_id: str, safe: bool = False) -> int: + """ + Delete a scan by its ID. + + Args: + scan_id: The scan identifier. + safe: If True, suppress exceptions and return structured error responses. + + Returns: + The HTTP status code returned by the server on success, + or the error status code returned by the server on failure. + + Raises: + Raise exceptions if safe is False; HTTP errors are caught and the corresponding status code is returned. + + See: DELETE /scans/{id} in the openvasd API documentation. + """ + try: + response = self._client.delete( + f"/scans/{urllib.parse.quote(scan_id)}" + ) + response.raise_for_status() + return response.status_code + except httpx.HTTPStatusError as e: + if safe: + return e.response.status_code + raise + + def get_all(self, safe: bool = False) -> httpx.Response: + """ + Retrieve the list of all available scans. + + Args: + safe: If True, suppress exceptions and return structured error responses. + + Returns: + The full HTTP response of the GET /scans request. + + Raises: + httpx.HTTPStatusError: If the request fails. + + See: GET /scans in the openvasd API documentation. + """ + try: + response = self._client.get("/scans") + response.raise_for_status() + return response + except httpx.HTTPStatusError as e: + if safe: + return e.response + raise + + def get(self, scan_id: str, safe: bool = False) -> httpx.Response: + """ + Retrieve metadata of a single scan by ID. + + Args: + scan_id: The scan identifier. + safe: If True, suppress exceptions and return structured error responses. + + Returns: + The full HTTP response of the GET /scans/{id} request. + + Raises: + httpx.HTTPStatusError: If the request fails. + + See: GET /scans/{id} in the openvasd API documentation. + """ + try: + response = self._client.get(f"/scans/{urllib.parse.quote(scan_id)}") + response.raise_for_status() + return response + except httpx.HTTPStatusError as e: + if safe: + return e.response + raise + + def get_results( + self, + scan_id: str, + range_start: Optional[int] = None, + range_end: Optional[int] = None, + safe: bool = False, + ) -> httpx.Response: + """ + Retrieve a range of results for a given scan. + + Args: + scan_id: The scan identifier. + range_start: Optional start index for paginated results. + range_end: Optional end index for paginated results. + safe: If True, suppress exceptions and return structured error responses. + + Returns: + The full HTTP response of the GET /scans/{id}/results request. + + Raises: + InvalidArgumentType: If provided range values are not integers. + httpx.HTTPStatusError: If the request fails. + + See: GET /scans/{id}/results in the openvasd API documentation. + """ + params = {} + if range_start is not None: + if not isinstance(range_start, int): + raise InvalidArgumentType("range_start") + if range_end is not None: + if not isinstance(range_end, int): + raise InvalidArgumentType("range_end") + params["range"] = f"{range_start}-{range_end}" + else: + params["range"] = str(range_start) + elif range_end is not None: + if not isinstance(range_end, int): + raise InvalidArgumentType("range_end") + params["range"] = f"0-{range_end}" + + try: + response = self._client.get( + f"/scans/{urllib.parse.quote(scan_id)}/results", params=params + ) + response.raise_for_status() + return response + except httpx.HTTPStatusError as e: + if safe: + return e.response + raise + + def get_result( + self, scan_id: str, result_id: Union[str, int], safe: bool = False + ) -> httpx.Response: + """ + Retrieve a specific scan result. + + Args: + scan_id: The scan identifier. + result_id: The specific result ID to fetch. + safe: If True, suppress exceptions and return structured error responses. + + Returns: + The full HTTP response of the GET /scans/{id}/results/{rid} request. + + Raises: + httpx.HTTPStatusError: If the request fails. + + See: GET /scans/{id}/results/{rid} in the openvasd API documentation. + """ + try: + response = self._client.get( + f"/scans/{urllib.parse.quote(scan_id)}/results/{urllib.parse.quote(str(result_id))}" + ) + response.raise_for_status() + return response + except httpx.HTTPStatusError as e: + if safe: + return e.response + raise + + def get_status(self, scan_id: str, safe: bool = False) -> httpx.Response: + """ + Retrieve the status of a scan. + + Args: + scan_id: The scan identifier. + safe: If True, suppress exceptions and return structured error responses. + + Returns: + The full HTTP response of the GET /scans/{id}/status request. + + Raises: + httpx.HTTPStatusError: If the request fails. + + See: GET /scans/{id}/status in the openvasd API documentation. + """ + try: + response = self._client.get( + f"/scans/{urllib.parse.quote(scan_id)}/status" + ) + response.raise_for_status() + return response + except httpx.HTTPStatusError as e: + if safe: + return e.response + raise + + def run_action( + self, scan_id: str, action: ScanAction, safe: bool = False + ) -> int: + """ + Perform a scan action (start or stop) for the given scan ID. + + Args: + scan_id: The unique identifier of the scan. + action: A member of the ScanAction enum (e.g., ScanAction.START or ScanAction.STOP). + safe: If True, suppress exceptions and return structured error responses. + + Returns: + The HTTP status code returned by the server on success, + or the error status code returned by the server on failure. + + Raises: + Does not raise exceptions; HTTP errors are caught and the corresponding status code is returned. + + See: POST /scans/{id} in the openvasd API documentation. + """ + try: + response = self._client.post( + f"/scans/{urllib.parse.quote(scan_id)}", + json={"action": str(action.value)}, + ) + response.raise_for_status() + return response.status_code + except httpx.HTTPStatusError as e: + if safe: + return e.response.status_code + raise + + def start(self, scan_id: str, safe: bool = False) -> int: + """ + Start the scan identified by the given scan ID. + + Args: + scan_id: The unique identifier of the scan. + safe: If True, suppress exceptions and return structured error responses. + + Returns: + HTTP status code (e.g., 200) if successful, or error code (e.g., 404, 500) if the request fails, + and safe is False. + + See: POST /scans/{id} with action=start in the openvasd API documentation. + """ + return self.run_action(scan_id, ScanAction.START, safe) + + def stop(self, scan_id: str, safe: bool = False) -> int: + """ + Stop the scan identified by the given scan ID. + + Args: + scan_id: The unique identifier of the scan. + safe: If True, suppress exceptions and return structured error responses. + + Returns: + HTTP status code (e.g., 200) if successful, or error code (e.g., 404, 500) if the request fails, + and safe is False. + + See: POST /scans/{id} with action=stop in the openvasd API documentation. + """ + return self.run_action(scan_id, ScanAction.STOP, safe) diff --git a/gvm/protocols/http/openvasd/vts.py b/gvm/protocols/http/openvasd/vts.py new file mode 100644 index 00000000..a28fb956 --- /dev/null +++ b/gvm/protocols/http/openvasd/vts.py @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +""" +API wrapper for accessing vulnerability test (VT) metadata from the openvasd HTTP API. +""" + +import urllib.parse + +import httpx + + +class VtsAPI: + """ + Provides access to the openvasd /vts endpoints. + + This includes retrieving the list of all available vulnerability tests + as well as fetching detailed information for individual VTs by OID. + """ + + def __init__(self, client: httpx.Client): + """ + Initialize a VtsAPI instance. + + Args: + client: An `httpx.Client` instance configured to communicate with the openvasd server. + """ + self._client = client + + def get_all(self, safe: bool = False) -> httpx.Response: + """ + Retrieve the list of all available vulnerability tests (VTs). + + This corresponds to a GET request to `/vts`. + + Args: + safe: If True, suppress exceptions and return structured error responses. + + Returns: + The full `httpx.Response` containing a JSON list of VT entries. + + Raises: + httpx.HTTPStatusError: If the server returns a non-success status and safe is False. + + See: GET /vts in the openvasd API documentation. + """ + try: + response = self._client.get("/vts") + response.raise_for_status() + return response + except httpx.HTTPStatusError as e: + if safe: + return e.response + raise + + def get(self, oid: str, safe: bool = False) -> httpx.Response: + """ + Retrieve detailed information about a specific VT by OID. + + This corresponds to a GET request to `/vts/{oid}`. + + Args: + oid: The OID (object identifier) of the vulnerability test. + safe: If True, suppress exceptions and return structured error responses. + + Returns: + The full `httpx.Response` containing VT metadata for the given OID. + + Raises: + httpx.HTTPStatusError: If the server returns a non-success status and safe is False. + + See: GET /vts/{id} in the openvasd API documentation. + """ + quoted_oid = urllib.parse.quote(oid) + try: + response = self._client.get(f"/vts/{quoted_oid}") + response.raise_for_status() + return response + except httpx.HTTPStatusError as e: + if safe: + return e.response + raise diff --git a/tests/protocols/http/openvasd/test_client.py b/tests/protocols/http/openvasd/test_client.py new file mode 100644 index 00000000..3f8648c8 --- /dev/null +++ b/tests/protocols/http/openvasd/test_client.py @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import ssl +import unittest +from unittest.mock import MagicMock, patch + +from gvm.protocols.http.openvasd._client import OpenvasdClient + + +class TestOpenvasdClient(unittest.TestCase): + + @patch("gvm.protocols.http.openvasd._client.Client") + def test_init_without_tls_or_api_key(self, mock_httpx_client): + client = OpenvasdClient("localhost") + mock_httpx_client.assert_called_once() + args, kwargs = mock_httpx_client.call_args + self.assertEqual(kwargs["base_url"], "http://localhost:3000") + self.assertFalse(kwargs["verify"]) + self.assertNotIn("X-API-KEY", kwargs["headers"]) + self.assertEqual(client.client, mock_httpx_client()) + + @patch("gvm.protocols.http.openvasd._client.Client") + def test_init_with_api_key_only(self, mock_httpx_client): + client = OpenvasdClient("localhost", api_key="secret") + args, kwargs = mock_httpx_client.call_args + self.assertEqual(kwargs["headers"]["X-API-KEY"], "secret") + self.assertEqual(kwargs["base_url"], "http://localhost:3000") + self.assertFalse(kwargs["verify"]) + self.assertEqual(client.client, mock_httpx_client()) + + @patch("gvm.protocols.http.openvasd._client.ssl.create_default_context") + @patch("gvm.protocols.http.openvasd._client.Client") + def test_init_with_mtls_tuple( + self, mock_httpx_client, mock_ssl_ctx_factory + ): + mock_context = MagicMock(spec=ssl.SSLContext) + mock_ssl_ctx_factory.return_value = mock_context + + OpenvasdClient( + "localhost", + server_ca_path="/path/ca.pem", + client_cert_paths=("/path/cert.pem", "/path/key.pem"), + ) + + mock_ssl_ctx_factory.assert_called_once_with( + ssl.Purpose.SERVER_AUTH, cafile="/path/ca.pem" + ) + mock_context.load_cert_chain.assert_called_once_with( + certfile="/path/cert.pem", keyfile="/path/key.pem" + ) + mock_httpx_client.assert_called_once() + args, kwargs = mock_httpx_client.call_args + self.assertEqual(kwargs["base_url"], "https://localhost:3000") + self.assertEqual(kwargs["verify"], mock_context) + + @patch("gvm.protocols.http.openvasd._client.ssl.create_default_context") + @patch("gvm.protocols.http.openvasd._client.Client") + def test_init_with_mtls_single_cert( + self, mock_httpx_client, mock_ssl_ctx_factory + ): + mock_context = MagicMock(spec=ssl.SSLContext) + mock_ssl_ctx_factory.return_value = mock_context + + OpenvasdClient( + "localhost", + server_ca_path="/path/ca.pem", + client_cert_paths="/path/client.pem", + ) + + mock_context.load_cert_chain.assert_called_once_with( + certfile="/path/client.pem" + ) + args, kwargs = mock_httpx_client.call_args + self.assertEqual(kwargs["base_url"], "https://localhost:3000") + self.assertEqual(kwargs["verify"], mock_context) diff --git a/tests/protocols/http/openvasd/test_health.py b/tests/protocols/http/openvasd/test_health.py new file mode 100644 index 00000000..bbf2a935 --- /dev/null +++ b/tests/protocols/http/openvasd/test_health.py @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import unittest +from unittest.mock import MagicMock + +import httpx + +from gvm.protocols.http.openvasd.health import HealthAPI + + +def _mock_response(status_code=200): + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = status_code + mock_response.raise_for_status = MagicMock() + return mock_response + + +class TestHealthAPI(unittest.TestCase): + + def setUp(self): + self.mock_client = MagicMock(spec=httpx.Client) + self.health_api = HealthAPI(self.mock_client) + + def test_alive_returns_status_code(self): + mock_response = _mock_response(200) + self.mock_client.get.return_value = mock_response + + result = self.health_api.get_alive() + self.mock_client.get.assert_called_once_with("/health/alive") + mock_response.raise_for_status.assert_called_once() + self.assertEqual(result, 200) + + def test_alive_raises_httpx_error_returns_503_with_safe_true(self): + self.mock_client.get.side_effect = httpx.HTTPStatusError( + "Not OK", request=MagicMock(), response=MagicMock(status_code=503) + ) + + result = self.health_api.get_alive(safe=True) + self.assertEqual(result, 503) + + def test_alive_raises_httpx_error(self): + self.mock_client.get.side_effect = httpx.HTTPStatusError( + "Not OK", request=MagicMock(), response=MagicMock(status_code=503) + ) + with self.assertRaises(httpx.HTTPStatusError): + self.health_api.get_alive() + + def test_ready_returns_status_code(self): + mock_response = _mock_response(204) + self.mock_client.get.return_value = mock_response + + result = self.health_api.get_ready() + self.mock_client.get.assert_called_once_with("/health/ready") + mock_response.raise_for_status.assert_called_once() + self.assertEqual(result, 204) + + def test_ready_raises_httpx_error_returns_503_with_safe_true(self): + self.mock_client.get.side_effect = httpx.HTTPStatusError( + "Not OK", request=MagicMock(), response=MagicMock(status_code=503) + ) + + result = self.health_api.get_ready(safe=True) + self.assertEqual(result, 503) + + def test_ready_raises_httpx_error(self): + self.mock_client.get.side_effect = httpx.HTTPStatusError( + "Not OK", request=MagicMock(), response=MagicMock(status_code=503) + ) + with self.assertRaises(httpx.HTTPStatusError): + self.health_api.get_ready() + + def test_started_returns_status_code(self): + mock_response = _mock_response(202) + self.mock_client.get.return_value = mock_response + + result = self.health_api.get_started() + self.mock_client.get.assert_called_once_with("/health/started") + mock_response.raise_for_status.assert_called_once() + self.assertEqual(result, 202) + + def test_started_raises_httpx_error_returns_503_with_safe_true(self): + self.mock_client.get.side_effect = httpx.HTTPStatusError( + "Not OK", request=MagicMock(), response=MagicMock(status_code=503) + ) + + result = self.health_api.get_started(safe=True) + self.assertEqual(result, 503) + + def test_started_raises_httpx_error(self): + self.mock_client.get.side_effect = httpx.HTTPStatusError( + "Not OK", request=MagicMock(), response=MagicMock(status_code=503) + ) + + with self.assertRaises(httpx.HTTPStatusError): + self.health_api.get_started() diff --git a/tests/protocols/http/openvasd/test_metadata.py b/tests/protocols/http/openvasd/test_metadata.py new file mode 100644 index 00000000..7d5b4dd5 --- /dev/null +++ b/tests/protocols/http/openvasd/test_metadata.py @@ -0,0 +1,102 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import unittest +from unittest.mock import MagicMock + +import httpx + +from gvm.protocols.http.openvasd.metadata import MetadataAPI + + +def _mock_head_response(status_code=200, headers=None): + response = MagicMock(spec=httpx.Response) + response.status_code = status_code + response.headers = headers or {} + response.raise_for_status = MagicMock() + return response + + +class TestMetadataAPI(unittest.TestCase): + def setUp(self): + self.mock_client = MagicMock(spec=httpx.Client) + self.api = MetadataAPI(self.mock_client) + + def test_get_successful(self): + headers = { + "api-version": "1.0", + "feed-version": "2025.01", + "authentication": "API-KEY", + } + mock_response = _mock_head_response(200, headers) + self.mock_client.head.return_value = mock_response + + result = self.api.get() + self.mock_client.head.assert_called_once_with("/") + mock_response.raise_for_status.assert_called_once() + self.assertEqual(result, headers) + + def test_get_unauthorized_with_safe_true(self): + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 401 + + self.mock_client.head.side_effect = httpx.HTTPStatusError( + "Unauthorized", request=MagicMock(), response=mock_response + ) + + result = self.api.get(safe=True) + self.assertEqual(result, {"error": "Unauthorized", "status_code": 401}) + + def test_get_failure_raises_httpx_error(self): + mock_response = _mock_head_response(500, None) + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Server failed", + request=MagicMock(), + response=MagicMock(status_code=500), + ) + self.mock_client.head.return_value = mock_response + + with self.assertRaises(httpx.HTTPStatusError): + self.api.get() + + self.mock_client.head.assert_called_once_with("/") + + def test_get_scans_successful(self): + headers = { + "api-version": "1.0", + "feed-version": "2025.01", + "authentication": "API-KEY", + } + mock_response = _mock_head_response(200, headers) + self.mock_client.head.return_value = mock_response + + result = self.api.get_scans() + self.mock_client.head.assert_called_once_with("/scans") + mock_response.raise_for_status.assert_called_once() + self.assertEqual(result, headers) + + def test_get_scans_unauthorized_with_safe_true(self): + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 401 + + self.mock_client.head.side_effect = httpx.HTTPStatusError( + "Unauthorized", request=MagicMock(), response=mock_response + ) + + result = self.api.get_scans(safe=True) + self.assertEqual(result, {"error": "Unauthorized", "status_code": 401}) + + def test_get_scans_failure_raises_httpx_error(self): + mock_response = _mock_head_response(500, None) + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Server failed", + request=MagicMock(), + response=MagicMock(status_code=500), + ) + self.mock_client.head.return_value = mock_response + + with self.assertRaises(httpx.HTTPStatusError): + self.api.get_scans() + + self.mock_client.head.assert_called_once_with("/scans") diff --git a/tests/protocols/http/openvasd/test_notus.py b/tests/protocols/http/openvasd/test_notus.py new file mode 100644 index 00000000..2cea6bf9 --- /dev/null +++ b/tests/protocols/http/openvasd/test_notus.py @@ -0,0 +1,94 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import unittest +from unittest.mock import MagicMock + +import httpx + +from gvm.protocols.http.openvasd.notus import NotusAPI + + +def _mock_response(status_code=200, json_data=None): + response = MagicMock(spec=httpx.Response) + response.status_code = status_code + response.json.return_value = json_data or [] + response.raise_for_status = MagicMock() + return response + + +class TestNotusAPI(unittest.TestCase): + def setUp(self): + self.mock_client = MagicMock(spec=httpx.Client) + self.api = NotusAPI(self.mock_client) + + def test_get_os_list_success(self): + mock_response = _mock_response(json_data=["debian", "alpine"]) + self.mock_client.get.return_value = mock_response + + result = self.api.get_os_list() + self.mock_client.get.assert_called_once_with("/notus") + mock_response.raise_for_status.assert_called_once() + self.assertEqual(result, mock_response) + + def test_get_os_list_http_error_with_safe_true(self): + mock_response = MagicMock() + mock_response.status_code = 500 + + self.mock_client.get.side_effect = httpx.HTTPStatusError( + "Internal Server Error", request=MagicMock(), response=mock_response + ) + + result = self.api.get_os_list(safe=True) + + self.assertEqual(result, mock_response) + + def test_get_os_list_http_error(self): + self.mock_client.get.side_effect = httpx.HTTPStatusError( + "Error", request=MagicMock(), response=MagicMock(status_code=500) + ) + + with self.assertRaises(httpx.HTTPStatusError): + self.api.get_os_list() + + def test_run_scan_success(self): + mock_response = _mock_response(json_data={"vulnerabilities": []}) + self.mock_client.post.return_value = mock_response + + result = self.api.run_scan("debian", ["openssl", "bash"]) + self.mock_client.post.assert_called_once_with( + "/notus/debian", json=["openssl", "bash"] + ) + mock_response.raise_for_status.assert_called_once() + self.assertEqual(result, mock_response) + + def test_run_scan_with_special_os_name(self): + mock_response = _mock_response() + self.mock_client.post.return_value = mock_response + + os_name = "alpine linux" + encoded = "alpine%20linux" + self.api.run_scan(os_name, ["musl"]) + self.mock_client.post.assert_called_once_with( + f"/notus/{encoded}", json=["musl"] + ) + + def test_run_scan_http_error(self): + self.mock_client.post.side_effect = httpx.HTTPStatusError( + "Error", request=MagicMock(), response=MagicMock(status_code=400) + ) + + with self.assertRaises(httpx.HTTPStatusError): + self.api.run_scan("ubuntu", ["curl"]) + + def test_run_scan_http_error_with_safe_true(self): + mock_response = MagicMock() + mock_response.status_code = 500 + self.mock_client.post.side_effect = httpx.HTTPStatusError( + "Internal Server Error", request=MagicMock(), response=mock_response + ) + + response = self.api.run_scan("ubuntu", ["curl"], safe=True) + + self.assertEqual(response, mock_response) diff --git a/tests/protocols/http/openvasd/test_openvasd1.py b/tests/protocols/http/openvasd/test_openvasd1.py new file mode 100644 index 00000000..17593608 --- /dev/null +++ b/tests/protocols/http/openvasd/test_openvasd1.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import unittest +from unittest.mock import MagicMock, patch + +from gvm.protocols.http.openvasd.openvasdv1 import OpenvasdHttpApiV1 + + +class TestOpenvasdHttpApiV1(unittest.TestCase): + + @patch("gvm.protocols.http.openvasd.openvasdv1.OpenvasdClient") + def test_initializes_all_sub_apis(self, mock_openvasd_client_class): + mock_httpx_client = MagicMock() + mock_openvasd_client_instance = MagicMock() + mock_openvasd_client_instance.client = mock_httpx_client + mock_openvasd_client_class.return_value = mock_openvasd_client_instance + + api = OpenvasdHttpApiV1( + host_name="localhost", + port=3000, + api_key="test-key", + server_ca_path="/path/to/ca.pem", + client_cert_paths=("/path/to/client.pem", "/path/to/key.pem"), + ) + + mock_openvasd_client_class.assert_called_once_with( + host_name="localhost", + port=3000, + api_key="test-key", + server_ca_path="/path/to/ca.pem", + client_cert_paths=("/path/to/client.pem", "/path/to/key.pem"), + ) + + self.assertEqual(api._client, mock_httpx_client) + self.assertEqual(api.health._client, mock_httpx_client) + self.assertEqual(api.metadata._client, mock_httpx_client) + self.assertEqual(api.notus._client, mock_httpx_client) + self.assertEqual(api.scans._client, mock_httpx_client) + self.assertEqual(api.vts._client, mock_httpx_client) diff --git a/tests/protocols/http/openvasd/test_scans.py b/tests/protocols/http/openvasd/test_scans.py new file mode 100644 index 00000000..54c7d0c2 --- /dev/null +++ b/tests/protocols/http/openvasd/test_scans.py @@ -0,0 +1,436 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import unittest +from unittest.mock import MagicMock + +import httpx + +from gvm.errors import InvalidArgumentType +from gvm.protocols.http.openvasd.scans import ScanAction, ScansAPI + + +def _mock_response(status_code=200, json_data=None): + resp = MagicMock(spec=httpx.Response) + resp.status_code = status_code + resp.json.return_value = json_data or {} + resp.raise_for_status = MagicMock() + return resp + + +class TestScansAPI(unittest.TestCase): + + def setUp(self): + self.mock_client = MagicMock(spec=httpx.Client) + self.api = ScansAPI(self.mock_client) + + def test_create_scan_success(self): + mock_resp = _mock_response() + self.mock_client.post.return_value = mock_resp + + self.api.create({"host": "localhost"}, [{"id": "1"}], {"threads": 4}) + self.mock_client.post.assert_called_once_with( + "/scans", + json={ + "target": {"host": "localhost"}, + "vts": [{"id": "1"}], + "scan_preferences": {"threads": 4}, + }, + ) + + def test_create_scan_without_scan_preferences(self): + mock_resp = _mock_response() + self.mock_client.post.return_value = mock_resp + + self.api.create({"host": "localhost"}, [{"id": "1"}]) + + self.mock_client.post.assert_called_once_with( + "/scans", + json={"target": {"host": "localhost"}, "vts": [{"id": "1"}]}, + ) + + def test_create_scan_failure_raises_httpx_error(self): + mock_response = _mock_response() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Bad Request", + request=MagicMock(), + response=MagicMock(status_code=400), + ) + self.mock_client.post.return_value = mock_response + + with self.assertRaises(httpx.HTTPStatusError): + self.api.create({"host": "localhost"}, [{"id": "1"}], {"test": 4}) + + self.mock_client.post.assert_called_once_with( + "/scans", + json={ + "target": {"host": "localhost"}, + "vts": [{"id": "1"}], + "scan_preferences": {"test": 4}, + }, + ) + + def test_create_scan_failure_httpx_error_with_safe_true(self): + mock_response = _mock_response() + mock_http_error = httpx.HTTPStatusError( + "Bad Request", + request=MagicMock(), + response=MagicMock(status_code=400), + ) + mock_response.raise_for_status.side_effect = mock_http_error + self.mock_client.post.return_value = mock_response + + response = self.api.create( + {"host": "localhost"}, [{"id": "1"}], {"test": 4}, safe=True + ) + + self.mock_client.post.assert_called_once_with( + "/scans", + json={ + "target": {"host": "localhost"}, + "vts": [{"id": "1"}], + "scan_preferences": {"test": 4}, + }, + ) + + self.assertEqual(response, mock_http_error.response) + self.assertEqual(response.status_code, 400) + + def test_delete_scan_success(self): + mock_resp = _mock_response(status_code=204) + self.mock_client.delete.return_value = mock_resp + + result = self.api.delete("scan-123") + self.assertEqual(result, 204) + + def test_delete_scan_returns_500_on_httpx_error_with_safe_true(self): + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 500 + + self.mock_client.delete.side_effect = httpx.HTTPStatusError( + "failed", request=MagicMock(), response=mock_response + ) + + status = self.api.delete("scan-1", True) + self.assertEqual(status, 500) + + def test_delete_scan_raise_on_httpx_error(self): + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 500 + + self.mock_client.delete.side_effect = httpx.HTTPStatusError( + "failed", request=MagicMock(), response=mock_response + ) + + with self.assertRaises(httpx.HTTPStatusError): + self.api.delete("scan-1") + + def test_get_all_scans(self): + mock_resp = _mock_response() + self.mock_client.get.return_value = mock_resp + + result = self.api.get_all() + self.mock_client.get.assert_called_once_with("/scans") + self.assertEqual(result, mock_resp) + + def test_get_all_scans_failure_raises_httpx_error(self): + mock_response = _mock_response() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Server failed", + request=MagicMock(), + response=MagicMock(status_code=500), + ) + self.mock_client.get.return_value = mock_response + + with self.assertRaises(httpx.HTTPStatusError): + self.api.get_all() + + self.mock_client.get.assert_called_once_with("/scans") + + def test_get_all_scans_failure_returns_500_with_safe_true(self): + mock_response = _mock_response() + mock_http_error = httpx.HTTPStatusError( + "Server failed", + request=MagicMock(), + response=MagicMock(status_code=500), + ) + mock_response.raise_for_status.side_effect = mock_http_error + self.mock_client.get.return_value = mock_response + + response = self.api.get_all(safe=True) + + self.mock_client.get.assert_called_once_with("/scans") + self.assertEqual(response, mock_http_error.response) + self.assertEqual(response.status_code, 500) + + def test_get_scan_by_id(self): + mock_resp = _mock_response() + self.mock_client.get.return_value = mock_resp + + result = self.api.get("scan-1") + self.mock_client.get.assert_called_once_with("/scans/scan-1") + self.assertEqual(result, mock_resp) + + def test_get_scan_by_id_failure_raises_httpx_error(self): + mock_response = _mock_response() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Server failed", + request=MagicMock(), + response=MagicMock(status_code=500), + ) + self.mock_client.get.return_value = mock_response + + with self.assertRaises(httpx.HTTPStatusError): + self.api.get("scan-1") + + self.mock_client.get.assert_called_once_with("/scans/scan-1") + + def test_get_scan_by_id_failure_return_500_httpx_error_with_safe_true(self): + mock_response = _mock_response() + mock_http_error = httpx.HTTPStatusError( + "Server failed", + request=MagicMock(), + response=MagicMock(status_code=500), + ) + mock_response.raise_for_status.side_effect = mock_http_error + self.mock_client.get.return_value = mock_response + + response = self.api.get("scan-1", safe=True) + + self.mock_client.get.assert_called_once_with("/scans/scan-1") + self.assertEqual(response, mock_http_error.response) + self.assertEqual(response.status_code, 500) + + def test_get_results_with_range(self): + mock_resp = _mock_response() + self.mock_client.get.return_value = mock_resp + + self.api.get_results("scan-1", 5, 10) + self.mock_client.get.assert_called_once_with( + "/scans/scan-1/results", params={"range": "5-10"} + ) + + def test_get_results_only_range_start(self): + mock_response = _mock_response() + self.mock_client.get.return_value = mock_response + + result = self.api.get_results("scan-1", range_start=7) + self.mock_client.get.assert_called_once_with( + "/scans/scan-1/results", params={"range": "7"} + ) + self.assertEqual(result, mock_response) + + def test_get_results_only_range_end(self): + mock_response = _mock_response() + self.mock_client.get.return_value = mock_response + + result = self.api.get_results("scan-1", range_end=9) + self.mock_client.get.assert_called_once_with( + "/scans/scan-1/results", params={"range": "0-9"} + ) + self.assertEqual(result, mock_response) + + def test_get_results_without_range(self): + mock_response = _mock_response() + self.mock_client.get.return_value = mock_response + + result = self.api.get_results("scan-1") + self.mock_client.get.assert_called_once_with( + "/scans/scan-1/results", params={} + ) + self.assertEqual(result, mock_response) + + def test_get_results_invalid_range_type_for_range_start(self): + with self.assertRaises(InvalidArgumentType): + self.api.get_results("scan-1", "wrong", 5) + + def test_get_results_invalid_range_type_for_range_end(self): + with self.assertRaises(InvalidArgumentType): + self.api.get_results("scan-1", 5, "wrong") + + def test_get_results_invalid_range_end_type_only(self): + with self.assertRaises(InvalidArgumentType): + self.api.get_results("scan-1", range_end="not-an-int") + + def test_get_results_with_range_failure_raises_httpx_error(self): + mock_response = _mock_response() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Server failed", + request=MagicMock(), + response=MagicMock(status_code=500), + ) + self.mock_client.get.return_value = mock_response + + with self.assertRaises(httpx.HTTPStatusError): + self.api.get_results("scan-1", 5, 10) + + self.mock_client.get.assert_called_once_with( + "/scans/scan-1/results", params={"range": "5-10"} + ) + + def test_get_results_with_range_failure_return_500_httpx_error_with_safe_true( + self, + ): + mock_response = _mock_response() + mock_http_error = httpx.HTTPStatusError( + "Server failed", + request=MagicMock(), + response=MagicMock(status_code=500), + ) + mock_response.raise_for_status.side_effect = mock_http_error + self.mock_client.get.return_value = mock_response + + response = self.api.get_results("scan-1", 5, 10, safe=True) + + self.mock_client.get.assert_called_once_with( + "/scans/scan-1/results", params={"range": "5-10"} + ) + self.assertEqual(response, mock_http_error.response) + self.assertEqual(response.status_code, 500) + + def test_get_result(self): + mock_resp = _mock_response() + self.mock_client.get.return_value = mock_resp + + self.api.get_result("scan-1", 99) + self.mock_client.get.assert_called_once_with("/scans/scan-1/results/99") + + def test_get_result_failure_raises_httpx_error(self): + mock_response = _mock_response() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Server failed", + request=MagicMock(), + response=MagicMock(status_code=500), + ) + self.mock_client.get.return_value = mock_response + + with self.assertRaises(httpx.HTTPStatusError): + self.api.get_result("scan-1", 99) + + self.mock_client.get.assert_called_once_with("/scans/scan-1/results/99") + + def test_get_result_failure_return_500_httpx_error_with_safe_true(self): + mock_response = _mock_response() + mock_http_error = httpx.HTTPStatusError( + "Server failed", + request=MagicMock(), + response=MagicMock(status_code=500), + ) + mock_response.raise_for_status.side_effect = mock_http_error + self.mock_client.get.return_value = mock_response + + response = self.api.get_result("scan-1", 99, safe=True) + + self.mock_client.get.assert_called_once_with("/scans/scan-1/results/99") + self.assertEqual(response, mock_http_error.response) + self.assertEqual(response.status_code, 500) + + def test_get_status(self): + mock_resp = _mock_response() + self.mock_client.get.return_value = mock_resp + + self.api.get_status("scan-1") + self.mock_client.get.assert_called_once_with("/scans/scan-1/status") + + def test_get_status_failure_raises_httpx_error(self): + mock_response = _mock_response() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Server failed", + request=MagicMock(), + response=MagicMock(status_code=500), + ) + self.mock_client.get.return_value = mock_response + + with self.assertRaises(httpx.HTTPStatusError): + self.api.get_status("scan-1") + + self.mock_client.get.assert_called_once_with("/scans/scan-1/status") + + def test_get_status_failure_return_500_httpx_error_with_safe_true(self): + mock_response = _mock_response() + mock_http_error = httpx.HTTPStatusError( + "Server failed", + request=MagicMock(), + response=MagicMock(status_code=500), + ) + mock_response.raise_for_status.side_effect = mock_http_error + self.mock_client.get.return_value = mock_response + + response = self.api.get_status("scan-1", safe=True) + + self.mock_client.get.assert_called_once_with("/scans/scan-1/status") + self.assertEqual(response, mock_http_error.response) + self.assertEqual(response.status_code, 500) + + def test_run_action_success(self): + mock_resp = _mock_response(status_code=202) + self.mock_client.post.return_value = mock_resp + + status = self.api.run_action("scan-1", ScanAction.START) + self.assertEqual(status, 202) + + def test_run_action_failure_raise_httpx_error(self): + self.mock_client.post.side_effect = httpx.HTTPStatusError( + "failed", request=MagicMock(), response=MagicMock(status_code=500) + ) + with self.assertRaises(httpx.HTTPStatusError): + self.api.run_action("scan-1", ScanAction.STOP) + + def test_run_action_failure_returns_500_with_safe_true(self): + self.mock_client.post.side_effect = httpx.HTTPStatusError( + "failed", request=MagicMock(), response=MagicMock(status_code=500) + ) + + status = self.api.run_action("scan-1", ScanAction.STOP, safe=True) + self.assertEqual(status, 500) + + def test_start_scan(self): + self.api.run_action = MagicMock(return_value=200) + result = self.api.start("scan-abc") + self.api.run_action.assert_called_once_with( + "scan-abc", ScanAction.START, False + ) + self.assertEqual(result, 200) + + def test_start_scan_failure_raises_httpx_error(self): + self.api.run_action = MagicMock( + side_effect=httpx.HTTPStatusError( + "Scan start failed", + request=MagicMock(), + response=MagicMock(status_code=500), + ) + ) + + with self.assertRaises(httpx.HTTPStatusError): + self.api.start("scan-abc") + + def test_start_scan_failure_returns_500_with_safe_true(self): + self.api.run_action = MagicMock(return_value=500) + result = self.api.start("scan-abc", safe=True) + self.assertEqual(result, 500) + + def test_stop_scan(self): + self.api.run_action = MagicMock(return_value=200) + result = self.api.stop("scan-abc") + self.api.run_action.assert_called_once_with( + "scan-abc", ScanAction.STOP, False + ) + self.assertEqual(result, 200) + + def test_stop_scan_failure_raises_httpx_error(self): + self.api.run_action = MagicMock( + side_effect=httpx.HTTPStatusError( + "Scan stop failed", + request=MagicMock(), + response=MagicMock(status_code=500), + ) + ) + + with self.assertRaises(httpx.HTTPStatusError): + self.api.stop("scan-abc") + + def test_stop_scan_failure_returns_500_with_safe_true(self): + self.api.run_action = MagicMock(return_value=500) + result = self.api.stop("scan-abc", safe=True) + self.assertEqual(result, 500) diff --git a/tests/protocols/http/openvasd/test_vts.py b/tests/protocols/http/openvasd/test_vts.py new file mode 100644 index 00000000..0c9d2707 --- /dev/null +++ b/tests/protocols/http/openvasd/test_vts.py @@ -0,0 +1,89 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import unittest +from unittest.mock import MagicMock + +import httpx + +from gvm.protocols.http.openvasd.vts import VtsAPI + + +def _mock_response(status_code=200): + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = status_code + mock_response.raise_for_status = MagicMock() + return mock_response + + +class TestVtsAPI(unittest.TestCase): + + def setUp(self): + self.mock_client = MagicMock(spec=httpx.Client) + self.api = VtsAPI(self.mock_client) + + def test_get_all_returns_response(self): + mock_response = _mock_response(200) + self.mock_client.get.return_value = mock_response + + response = self.api.get_all() + self.mock_client.get.assert_called_once_with("/vts") + mock_response.raise_for_status.assert_called_once() + self.assertEqual(response, mock_response) + + def test_get_all_raises_httpx_error(self): + self.mock_client.get.side_effect = httpx.HTTPStatusError( + "Error", request=MagicMock(), response=MagicMock(status_code=500) + ) + with self.assertRaises(httpx.HTTPStatusError): + self.api.get_all() + + def test_get_all_returns_response_on_httpx_error_with_safe_true(self): + mock_response = _mock_response() + mock_http_error = httpx.HTTPStatusError( + "Server failed", + request=MagicMock(), + response=MagicMock(status_code=500), + ) + mock_response.raise_for_status.side_effect = mock_http_error + self.mock_client.get.return_value = mock_response + + result = self.api.get_all(safe=True) + + self.mock_client.get.assert_called_once_with("/vts") + self.assertEqual(result, mock_http_error.response) + + def test_get_by_oid_returns_response(self): + mock_response = _mock_response(200) + self.mock_client.get.return_value = mock_response + + oid = "1.3.6.1.4.1.25623.1.0.123456" + response = self.api.get(oid) + self.mock_client.get.assert_called_once_with(f"/vts/{oid}") + mock_response.raise_for_status.assert_called_once() + self.assertEqual(response, mock_response) + + def test_get_by_oid_raises_httpx_error(self): + self.mock_client.get.side_effect = httpx.HTTPStatusError( + "Not Found", + request=MagicMock(), + response=MagicMock(status_code=404), + ) + + with self.assertRaises(httpx.HTTPStatusError): + self.api.get("nonexistent-oid") + + def test_get_by_oid_response_on_httpx_error_with_safe_true(self): + mock_response = _mock_response() + mock_http_error = httpx.HTTPStatusError( + "Not Found", + request=MagicMock(), + response=MagicMock(status_code=404), + ) + mock_response.raise_for_status.side_effect = mock_http_error + self.mock_client.get.return_value = mock_response + + response = self.api.get("nonexistent-oid", safe=True) + + self.assertEqual(response, mock_http_error.response) From 7af2fc164e13d5cf0f5c708151d9650152069ea9 Mon Sep 17 00:00:00 2001 From: ozgen Date: Mon, 23 Jun 2025 15:31:03 +0200 Subject: [PATCH 23/34] Doc: update and fix openvasd API documentation pages --- docs/api/health.rst | 7 +++++++ docs/api/metadata.rst | 7 +++++++ docs/api/notus.rst | 7 +++++++ docs/api/openvasdv1.rst | 18 +++++++++++++++--- docs/api/scans.rst | 7 +++++++ docs/api/vts.rst | 7 +++++++ 6 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 docs/api/health.rst create mode 100644 docs/api/metadata.rst create mode 100644 docs/api/notus.rst create mode 100644 docs/api/scans.rst create mode 100644 docs/api/vts.rst diff --git a/docs/api/health.rst b/docs/api/health.rst new file mode 100644 index 00000000..4d17c629 --- /dev/null +++ b/docs/api/health.rst @@ -0,0 +1,7 @@ +Health API +========== + +.. automodule:: gvm.protocols.http.openvasd.health + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/metadata.rst b/docs/api/metadata.rst new file mode 100644 index 00000000..2a6c7c9b --- /dev/null +++ b/docs/api/metadata.rst @@ -0,0 +1,7 @@ +Metadata API +============ + +.. automodule:: gvm.protocols.http.openvasd.metadata + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/api/notus.rst b/docs/api/notus.rst new file mode 100644 index 00000000..a09b632c --- /dev/null +++ b/docs/api/notus.rst @@ -0,0 +1,7 @@ +Notus API +========= + +.. automodule:: gvm.protocols.http.openvasd.notus + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/api/openvasdv1.rst b/docs/api/openvasdv1.rst index 2b061f8a..ccaeb4aa 100644 --- a/docs/api/openvasdv1.rst +++ b/docs/api/openvasdv1.rst @@ -3,7 +3,19 @@ openvasd v1 ^^^^^^^^^^^ -.. automodule:: gvm.protocols.http.openvasd.openvasd1 +.. automodule:: gvm.protocols.http.openvasd.openvasdv1 + :members: + :undoc-members: + :show-inheritance: -.. autoclass:: OpenvasdHttpApiV1 - :members: \ No newline at end of file +Submodules +---------- + +.. toctree:: + :maxdepth: 1 + + health + metadata + notus + scans + vts diff --git a/docs/api/scans.rst b/docs/api/scans.rst new file mode 100644 index 00000000..7c28d3fd --- /dev/null +++ b/docs/api/scans.rst @@ -0,0 +1,7 @@ +Scans API +========= + +.. automodule:: gvm.protocols.http.openvasd.scans + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/api/vts.rst b/docs/api/vts.rst new file mode 100644 index 00000000..3beea008 --- /dev/null +++ b/docs/api/vts.rst @@ -0,0 +1,7 @@ +Vts API +======= + +.. automodule:: gvm.protocols.http.openvasd.vts + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file From 6b2bbb04a8b01d7d128d5e3e9d22f41bad427e06 Mon Sep 17 00:00:00 2001 From: ozgen Date: Mon, 23 Jun 2025 16:31:04 +0200 Subject: [PATCH 24/34] fix the conflict in the poetry.lock --- poetry.lock | 49 ++++++++++++++++++++++++------------------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4a999376..faaffc9a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. [[package]] name = "alabaster" @@ -18,7 +18,7 @@ version = "4.9.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, @@ -32,7 +32,7 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] +test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] trio = ["trio (>=0.26.1)"] [[package]] @@ -114,7 +114,7 @@ files = [ ] [package.extras] -dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] +dev = ["backports.zoneinfo", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata"] [[package]] name = "bcrypt" @@ -567,7 +567,7 @@ files = [ ] [package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +toml = ["tomli"] [[package]] name = "cryptography" @@ -620,10 +620,10 @@ files = [ cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""} [package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs ; python_full_version >= \"3.8.0\"", "sphinx-rtd-theme (>=3.0.0) ; python_full_version >= \"3.8.0\""] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_full_version >= \"3.8.0\""] -pep8test = ["check-sdist ; python_full_version >= \"3.8.0\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] test = ["certifi (>=2024)", "cryptography-vectors (==45.0.5)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] @@ -647,7 +647,7 @@ version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["dev"] markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, @@ -716,7 +716,7 @@ version = "4.2.0" description = "Pure-Python HTTP/2 protocol implementation" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "h2-4.2.0-py3-none-any.whl", hash = "sha256:479a53ad425bb29af087f3458a61d30780bc818e4ebcf01f0b536ba916462ed0"}, {file = "h2-4.2.0.tar.gz", hash = "sha256:c8a52129695e88b1a0578d8d2cc6842bbd79128ac685463b887ee278126ad01f"}, @@ -732,7 +732,7 @@ version = "4.1.0" description = "Pure-Python HPACK header encoding" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496"}, {file = "hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca"}, @@ -744,7 +744,7 @@ version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -766,7 +766,7 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -780,7 +780,7 @@ httpcore = "==1.*" idna = "*" [package.extras] -brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +brotli = ["brotli", "brotlicffi"] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -792,7 +792,7 @@ version = "6.1.0" description = "Pure-Python HTTP/2 framing" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"}, {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"}, @@ -804,7 +804,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -842,12 +842,12 @@ files = [ zipp = ">=3.20" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +test = ["flufl.flake8", "importlib_resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] type = ["pytest-mypy"] [[package]] @@ -1247,8 +1247,8 @@ cryptography = ">=3.3" pynacl = ">=1.5" [package.extras] -all = ["gssapi (>=1.4.1) ; platform_system != \"Windows\"", "invoke (>=2.0)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8) ; platform_system == \"Windows\""] -gssapi = ["gssapi (>=1.4.1) ; platform_system != \"Windows\"", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8) ; platform_system == \"Windows\""] +all = ["gssapi (>=1.4.1)", "invoke (>=2.0)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] +gssapi = ["gssapi (>=1.4.1)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] invoke = ["invoke (>=2.0)"] [[package]] @@ -1552,7 +1552,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -1833,7 +1833,6 @@ files = [ {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, ] -markers = {main = "python_version < \"3.13\""} [[package]] name = "urllib3" @@ -1848,7 +1847,7 @@ files = [ ] [package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -1867,7 +1866,7 @@ files = [ ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] @@ -1877,4 +1876,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9.2" -content-hash = "5a19a437b167240b91207d5d281549beffcb24ee414425a88d3b6371bf7c96ad" +content-hash = "5b07223adfc7448bacbf31834a44d5ecdd010779a7f473fc34910c9e1a25bebe" From e93820973cec84d4804ae23f309473c88aca916a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Tue, 8 Jul 2025 09:34:37 +0200 Subject: [PATCH 25/34] Rename versioned module and drop Client class The client class just creates a httpx.Client instance. Therefore it should be a function. Rename the module and remove the v from the name to be consistent with other module names. --- gvm/protocols/http/openvasd/_client.py | 111 +++++++++--------- .../openvasd/{openvasdv1.py => openvasd1.py} | 12 +- tests/protocols/http/openvasd/test_client.py | 21 ++-- .../protocols/http/openvasd/test_openvasd1.py | 13 +- 4 files changed, 76 insertions(+), 81 deletions(-) rename gvm/protocols/http/openvasd/{openvasdv1.py => openvasd1.py} (86%) diff --git a/gvm/protocols/http/openvasd/_client.py b/gvm/protocols/http/openvasd/_client.py index 886aa69f..c82a5a09 100644 --- a/gvm/protocols/http/openvasd/_client.py +++ b/gvm/protocols/http/openvasd/_client.py @@ -3,79 +3,78 @@ # SPDX-License-Identifier: GPL-3.0-or-later """ -Client wrapper for initializing a connection to the openvasd HTTP API using optional mTLS authentication. +http client for initializing a connection to the openvasd HTTP API using optional mTLS authentication. """ import ssl +from os import PathLike from typing import Optional, Tuple, Union from httpx import Client +StrOrPathLike = Union[str, PathLike[str]] -class OpenvasdClient: + +def create_openvasd_http_client( + host_name: str, + *, + api_key: Optional[str] = None, + server_ca_path: Optional[StrOrPathLike] = None, + client_cert_paths: Optional[ + Union[StrOrPathLike, Tuple[StrOrPathLike, StrOrPathLike]] + ] = None, + port: int = 3000, +) -> Client: """ - The client wrapper around `httpx.Client` configured for mTLS-secured access or API KEY + Create a `httpx.Client` configured for mTLS-secured or API KEY access to an openvasd HTTP API instance. - """ - - def __init__( - self, - host_name: str, - *, - api_key: Optional[str] = None, - server_ca_path: Optional[str] = None, - client_cert_paths: Optional[Union[str, Tuple[str, str]]] = None, - port: int = 3000, - ): - """ - Initialize the OpenVASD HTTP client with optional mTLS and API key. - Args: - host_name: Hostname or IP of the OpenVASD server (e.g., "localhost"). - api_key: Optional API key used for authentication via HTTP headers. - server_ca_path: Path to the server's CA certificate (for verifying the server). - client_cert_paths: Path to the client certificate (str) or a tuple of - (cert_path, key_path) for mTLS authentication. - port: The port to connect to (default: 3000). + Args: + host_name: Hostname or IP of the OpenVASD server (e.g., "localhost"). + api_key: Optional API key used for authentication via HTTP headers. + server_ca_path: Path to the server's CA certificate (for verifying the server). + client_cert_paths: Path to the client certificate (str) or a tuple of + (cert_path, key_path) for mTLS authentication. + port: The port to connect to (default: 3000). - Behavior: - - If both `server_ca_path` and `client_cert_paths` are set, an mTLS connection - is established using an SSLContext. - - If not, `verify` is set to False (insecure), and HTTP is used instead of HTTPS. - HTTP connection needs api_key for authorization. - """ - headers = {} + Behavior: + - If both `server_ca_path` and `client_cert_paths` are set, an mTLS connection + is established using an SSLContext. + - If not, `verify` is set to False (insecure), and HTTP is used instead of HTTPS. + HTTP connection needs api_key for authorization. + """ + headers = {} - context: Optional[ssl.SSLContext] = None + context: Optional[ssl.SSLContext] = None - # Prepare mTLS SSL context if needed - if client_cert_paths and server_ca_path: - context = ssl.create_default_context( - ssl.Purpose.SERVER_AUTH, cafile=server_ca_path + # Prepare mTLS SSL context if needed + if client_cert_paths and server_ca_path: + context = ssl.create_default_context( + ssl.Purpose.SERVER_AUTH, cafile=server_ca_path + ) + if isinstance(client_cert_paths, tuple): + context.load_cert_chain( + certfile=client_cert_paths[0], keyfile=client_cert_paths[1] ) - if isinstance(client_cert_paths, tuple): - context.load_cert_chain( - certfile=client_cert_paths[0], keyfile=client_cert_paths[1] - ) - else: - context.load_cert_chain(certfile=client_cert_paths) + else: + context.load_cert_chain(certfile=client_cert_paths) - context.check_hostname = False - context.verify_mode = ssl.CERT_REQUIRED + context.check_hostname = False + context.verify_mode = ssl.CERT_REQUIRED - # Set verify based on context presence - verify: Union[bool, ssl.SSLContext] = context if context else False + # Set verify based on context presence + verify: Union[bool, ssl.SSLContext] = context if context else False - if api_key: - headers["X-API-KEY"] = api_key + if api_key: + headers["X-API-KEY"] = api_key - protocol = "https" if context else "http" - base_url = f"{protocol}://{host_name}:{port}" + protocol = "https" if context else "http" + base_url = f"{protocol}://{host_name}:{port}" - self.client = Client( - base_url=base_url, - headers=headers, - verify=verify, - http2=True, - timeout=10.0, - ) + return Client( + base_url=base_url, + headers=headers, + verify=verify, + http2=True, + timeout=10.0, + ) diff --git a/gvm/protocols/http/openvasd/openvasdv1.py b/gvm/protocols/http/openvasd/openvasd1.py similarity index 86% rename from gvm/protocols/http/openvasd/openvasdv1.py rename to gvm/protocols/http/openvasd/openvasd1.py index 981fd2fa..f2d4078a 100644 --- a/gvm/protocols/http/openvasd/openvasdv1.py +++ b/gvm/protocols/http/openvasd/openvasd1.py @@ -11,7 +11,7 @@ from typing import Optional, Tuple, Union -from ._client import OpenvasdClient +from ._client import StrOrPathLike, create_openvasd_http_client from .health import HealthAPI from .metadata import MetadataAPI from .notus import NotusAPI @@ -35,8 +35,10 @@ def __init__( port: int = 3000, *, api_key: Optional[str] = None, - server_ca_path: Optional[str] = None, - client_cert_paths: Optional[Union[str, Tuple[str, str]]] = None, + server_ca_path: Optional[StrOrPathLike] = None, + client_cert_paths: Optional[ + Union[StrOrPathLike, Tuple[StrOrPathLike, StrOrPathLike]] + ] = None, ): """ Initialize the OpenvasdHttpApiV1 entry point. @@ -52,13 +54,13 @@ def __init__( - Sets up an underlying `httpx.Client` using `OpenvasdClient`. - Initializes sub-API modules with the shared client instance. """ - self._client = OpenvasdClient( + self._client = create_openvasd_http_client( host_name=host_name, port=port, api_key=api_key, server_ca_path=server_ca_path, client_cert_paths=client_cert_paths, - ).client + ) # Sub-API modules self.health = HealthAPI(self._client) diff --git a/tests/protocols/http/openvasd/test_client.py b/tests/protocols/http/openvasd/test_client.py index 3f8648c8..2224a5f8 100644 --- a/tests/protocols/http/openvasd/test_client.py +++ b/tests/protocols/http/openvasd/test_client.py @@ -6,29 +6,26 @@ import unittest from unittest.mock import MagicMock, patch -from gvm.protocols.http.openvasd._client import OpenvasdClient +from gvm.protocols.http.openvasd._client import create_openvasd_http_client class TestOpenvasdClient(unittest.TestCase): - @patch("gvm.protocols.http.openvasd._client.Client") def test_init_without_tls_or_api_key(self, mock_httpx_client): - client = OpenvasdClient("localhost") + create_openvasd_http_client("localhost") mock_httpx_client.assert_called_once() - args, kwargs = mock_httpx_client.call_args + _, kwargs = mock_httpx_client.call_args self.assertEqual(kwargs["base_url"], "http://localhost:3000") self.assertFalse(kwargs["verify"]) self.assertNotIn("X-API-KEY", kwargs["headers"]) - self.assertEqual(client.client, mock_httpx_client()) @patch("gvm.protocols.http.openvasd._client.Client") def test_init_with_api_key_only(self, mock_httpx_client): - client = OpenvasdClient("localhost", api_key="secret") - args, kwargs = mock_httpx_client.call_args + create_openvasd_http_client("localhost", api_key="secret") + _, kwargs = mock_httpx_client.call_args self.assertEqual(kwargs["headers"]["X-API-KEY"], "secret") self.assertEqual(kwargs["base_url"], "http://localhost:3000") self.assertFalse(kwargs["verify"]) - self.assertEqual(client.client, mock_httpx_client()) @patch("gvm.protocols.http.openvasd._client.ssl.create_default_context") @patch("gvm.protocols.http.openvasd._client.Client") @@ -38,7 +35,7 @@ def test_init_with_mtls_tuple( mock_context = MagicMock(spec=ssl.SSLContext) mock_ssl_ctx_factory.return_value = mock_context - OpenvasdClient( + create_openvasd_http_client( "localhost", server_ca_path="/path/ca.pem", client_cert_paths=("/path/cert.pem", "/path/key.pem"), @@ -51,7 +48,7 @@ def test_init_with_mtls_tuple( certfile="/path/cert.pem", keyfile="/path/key.pem" ) mock_httpx_client.assert_called_once() - args, kwargs = mock_httpx_client.call_args + _, kwargs = mock_httpx_client.call_args self.assertEqual(kwargs["base_url"], "https://localhost:3000") self.assertEqual(kwargs["verify"], mock_context) @@ -63,7 +60,7 @@ def test_init_with_mtls_single_cert( mock_context = MagicMock(spec=ssl.SSLContext) mock_ssl_ctx_factory.return_value = mock_context - OpenvasdClient( + create_openvasd_http_client( "localhost", server_ca_path="/path/ca.pem", client_cert_paths="/path/client.pem", @@ -72,6 +69,6 @@ def test_init_with_mtls_single_cert( mock_context.load_cert_chain.assert_called_once_with( certfile="/path/client.pem" ) - args, kwargs = mock_httpx_client.call_args + _, kwargs = mock_httpx_client.call_args self.assertEqual(kwargs["base_url"], "https://localhost:3000") self.assertEqual(kwargs["verify"], mock_context) diff --git a/tests/protocols/http/openvasd/test_openvasd1.py b/tests/protocols/http/openvasd/test_openvasd1.py index 17593608..2c685d5c 100644 --- a/tests/protocols/http/openvasd/test_openvasd1.py +++ b/tests/protocols/http/openvasd/test_openvasd1.py @@ -5,17 +5,14 @@ import unittest from unittest.mock import MagicMock, patch -from gvm.protocols.http.openvasd.openvasdv1 import OpenvasdHttpApiV1 +from gvm.protocols.http.openvasd.openvasd1 import OpenvasdHttpApiV1 class TestOpenvasdHttpApiV1(unittest.TestCase): - - @patch("gvm.protocols.http.openvasd.openvasdv1.OpenvasdClient") - def test_initializes_all_sub_apis(self, mock_openvasd_client_class): + @patch("gvm.protocols.http.openvasd.openvasd1.create_openvasd_http_client") + def test_initializes_all_sub_apis(self, mock_crate_openvasd_client): mock_httpx_client = MagicMock() - mock_openvasd_client_instance = MagicMock() - mock_openvasd_client_instance.client = mock_httpx_client - mock_openvasd_client_class.return_value = mock_openvasd_client_instance + mock_crate_openvasd_client.return_value = mock_httpx_client api = OpenvasdHttpApiV1( host_name="localhost", @@ -25,7 +22,7 @@ def test_initializes_all_sub_apis(self, mock_openvasd_client_class): client_cert_paths=("/path/to/client.pem", "/path/to/key.pem"), ) - mock_openvasd_client_class.assert_called_once_with( + mock_crate_openvasd_client.assert_called_once_with( host_name="localhost", port=3000, api_key="test-key", From e108e88277274abb5122f894672d08b45980c84b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Tue, 8 Jul 2025 09:37:12 +0200 Subject: [PATCH 26/34] Rename openvasd API class Use a consistent naming. --- gvm/protocols/http/openvasd/openvasd1.py | 2 +- tests/protocols/http/openvasd/test_openvasd1.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gvm/protocols/http/openvasd/openvasd1.py b/gvm/protocols/http/openvasd/openvasd1.py index f2d4078a..6d4adcfd 100644 --- a/gvm/protocols/http/openvasd/openvasd1.py +++ b/gvm/protocols/http/openvasd/openvasd1.py @@ -19,7 +19,7 @@ from .vts import VtsAPI -class OpenvasdHttpApiV1: +class OpenvasdHttpAPIv1: """ High-level interface for accessing openvasd HTTP API v1 endpoints. diff --git a/tests/protocols/http/openvasd/test_openvasd1.py b/tests/protocols/http/openvasd/test_openvasd1.py index 2c685d5c..dba239f0 100644 --- a/tests/protocols/http/openvasd/test_openvasd1.py +++ b/tests/protocols/http/openvasd/test_openvasd1.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import MagicMock, patch -from gvm.protocols.http.openvasd.openvasd1 import OpenvasdHttpApiV1 +from gvm.protocols.http.openvasd.openvasd1 import OpenvasdHttpAPIv1 class TestOpenvasdHttpApiV1(unittest.TestCase): @@ -14,7 +14,7 @@ def test_initializes_all_sub_apis(self, mock_crate_openvasd_client): mock_httpx_client = MagicMock() mock_crate_openvasd_client.return_value = mock_httpx_client - api = OpenvasdHttpApiV1( + api = OpenvasdHttpAPIv1( host_name="localhost", port=3000, api_key="test-key", From 84a02328e1fc098680b25d85cdd3fa60d65ff8e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Tue, 8 Jul 2025 09:38:49 +0200 Subject: [PATCH 27/34] Mark OpenvasdHttpAPIv1 as public for gvm.protocols.http.openvasd Users should import the class from gvm.protocols.http.openvasd. --- gvm/protocols/http/openvasd/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/gvm/protocols/http/openvasd/__init__.py b/gvm/protocols/http/openvasd/__init__.py index 0c738276..d3b39e55 100644 --- a/gvm/protocols/http/openvasd/__init__.py +++ b/gvm/protocols/http/openvasd/__init__.py @@ -11,3 +11,7 @@ Usage: from gvm.protocols.http.openvasd import OpenvasdHttpApiV1 """ + +from .openvasd1 import OpenvasdHttpAPIv1 + +__all__ = ["OpenvasdHttpAPIv1"] From d3926956530ffc3fa6fa0fcf09428ba6d8336ce8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Tue, 8 Jul 2025 09:49:45 +0200 Subject: [PATCH 28/34] Mark all modules of openvasd API as private Users should not import anything from these modules. --- gvm/protocols/http/openvasd/__init__.py | 2 +- gvm/protocols/http/openvasd/{health.py => _health.py} | 0 .../http/openvasd/{metadata.py => _metadata.py} | 0 gvm/protocols/http/openvasd/{notus.py => _notus.py} | 0 .../http/openvasd/{openvasd1.py => _openvasd1.py} | 10 +++++----- gvm/protocols/http/openvasd/{scans.py => _scans.py} | 0 gvm/protocols/http/openvasd/{vts.py => _vts.py} | 0 tests/protocols/http/openvasd/test_health.py | 3 +-- tests/protocols/http/openvasd/test_metadata.py | 2 +- tests/protocols/http/openvasd/test_notus.py | 2 +- tests/protocols/http/openvasd/test_openvasd1.py | 4 ++-- tests/protocols/http/openvasd/test_scans.py | 3 +-- tests/protocols/http/openvasd/test_vts.py | 3 +-- 13 files changed, 13 insertions(+), 16 deletions(-) rename gvm/protocols/http/openvasd/{health.py => _health.py} (100%) rename gvm/protocols/http/openvasd/{metadata.py => _metadata.py} (100%) rename gvm/protocols/http/openvasd/{notus.py => _notus.py} (100%) rename gvm/protocols/http/openvasd/{openvasd1.py => _openvasd1.py} (93%) rename gvm/protocols/http/openvasd/{scans.py => _scans.py} (100%) rename gvm/protocols/http/openvasd/{vts.py => _vts.py} (100%) diff --git a/gvm/protocols/http/openvasd/__init__.py b/gvm/protocols/http/openvasd/__init__.py index d3b39e55..539d6ece 100644 --- a/gvm/protocols/http/openvasd/__init__.py +++ b/gvm/protocols/http/openvasd/__init__.py @@ -12,6 +12,6 @@ from gvm.protocols.http.openvasd import OpenvasdHttpApiV1 """ -from .openvasd1 import OpenvasdHttpAPIv1 +from ._openvasd1 import OpenvasdHttpAPIv1 __all__ = ["OpenvasdHttpAPIv1"] diff --git a/gvm/protocols/http/openvasd/health.py b/gvm/protocols/http/openvasd/_health.py similarity index 100% rename from gvm/protocols/http/openvasd/health.py rename to gvm/protocols/http/openvasd/_health.py diff --git a/gvm/protocols/http/openvasd/metadata.py b/gvm/protocols/http/openvasd/_metadata.py similarity index 100% rename from gvm/protocols/http/openvasd/metadata.py rename to gvm/protocols/http/openvasd/_metadata.py diff --git a/gvm/protocols/http/openvasd/notus.py b/gvm/protocols/http/openvasd/_notus.py similarity index 100% rename from gvm/protocols/http/openvasd/notus.py rename to gvm/protocols/http/openvasd/_notus.py diff --git a/gvm/protocols/http/openvasd/openvasd1.py b/gvm/protocols/http/openvasd/_openvasd1.py similarity index 93% rename from gvm/protocols/http/openvasd/openvasd1.py rename to gvm/protocols/http/openvasd/_openvasd1.py index 6d4adcfd..23bcdf61 100644 --- a/gvm/protocols/http/openvasd/openvasd1.py +++ b/gvm/protocols/http/openvasd/_openvasd1.py @@ -12,11 +12,11 @@ from typing import Optional, Tuple, Union from ._client import StrOrPathLike, create_openvasd_http_client -from .health import HealthAPI -from .metadata import MetadataAPI -from .notus import NotusAPI -from .scans import ScansAPI -from .vts import VtsAPI +from ._health import HealthAPI +from ._metadata import MetadataAPI +from ._notus import NotusAPI +from ._scans import ScansAPI +from ._vts import VtsAPI class OpenvasdHttpAPIv1: diff --git a/gvm/protocols/http/openvasd/scans.py b/gvm/protocols/http/openvasd/_scans.py similarity index 100% rename from gvm/protocols/http/openvasd/scans.py rename to gvm/protocols/http/openvasd/_scans.py diff --git a/gvm/protocols/http/openvasd/vts.py b/gvm/protocols/http/openvasd/_vts.py similarity index 100% rename from gvm/protocols/http/openvasd/vts.py rename to gvm/protocols/http/openvasd/_vts.py diff --git a/tests/protocols/http/openvasd/test_health.py b/tests/protocols/http/openvasd/test_health.py index bbf2a935..99676e4f 100644 --- a/tests/protocols/http/openvasd/test_health.py +++ b/tests/protocols/http/openvasd/test_health.py @@ -7,7 +7,7 @@ import httpx -from gvm.protocols.http.openvasd.health import HealthAPI +from gvm.protocols.http.openvasd._health import HealthAPI def _mock_response(status_code=200): @@ -18,7 +18,6 @@ def _mock_response(status_code=200): class TestHealthAPI(unittest.TestCase): - def setUp(self): self.mock_client = MagicMock(spec=httpx.Client) self.health_api = HealthAPI(self.mock_client) diff --git a/tests/protocols/http/openvasd/test_metadata.py b/tests/protocols/http/openvasd/test_metadata.py index 7d5b4dd5..f1a0e8ee 100644 --- a/tests/protocols/http/openvasd/test_metadata.py +++ b/tests/protocols/http/openvasd/test_metadata.py @@ -7,7 +7,7 @@ import httpx -from gvm.protocols.http.openvasd.metadata import MetadataAPI +from gvm.protocols.http.openvasd._metadata import MetadataAPI def _mock_head_response(status_code=200, headers=None): diff --git a/tests/protocols/http/openvasd/test_notus.py b/tests/protocols/http/openvasd/test_notus.py index 2cea6bf9..66471e63 100644 --- a/tests/protocols/http/openvasd/test_notus.py +++ b/tests/protocols/http/openvasd/test_notus.py @@ -7,7 +7,7 @@ import httpx -from gvm.protocols.http.openvasd.notus import NotusAPI +from gvm.protocols.http.openvasd._notus import NotusAPI def _mock_response(status_code=200, json_data=None): diff --git a/tests/protocols/http/openvasd/test_openvasd1.py b/tests/protocols/http/openvasd/test_openvasd1.py index dba239f0..5f46de0b 100644 --- a/tests/protocols/http/openvasd/test_openvasd1.py +++ b/tests/protocols/http/openvasd/test_openvasd1.py @@ -5,11 +5,11 @@ import unittest from unittest.mock import MagicMock, patch -from gvm.protocols.http.openvasd.openvasd1 import OpenvasdHttpAPIv1 +from gvm.protocols.http.openvasd import OpenvasdHttpAPIv1 class TestOpenvasdHttpApiV1(unittest.TestCase): - @patch("gvm.protocols.http.openvasd.openvasd1.create_openvasd_http_client") + @patch("gvm.protocols.http.openvasd._openvasd1.create_openvasd_http_client") def test_initializes_all_sub_apis(self, mock_crate_openvasd_client): mock_httpx_client = MagicMock() mock_crate_openvasd_client.return_value = mock_httpx_client diff --git a/tests/protocols/http/openvasd/test_scans.py b/tests/protocols/http/openvasd/test_scans.py index 54c7d0c2..9bd3de9e 100644 --- a/tests/protocols/http/openvasd/test_scans.py +++ b/tests/protocols/http/openvasd/test_scans.py @@ -8,7 +8,7 @@ import httpx from gvm.errors import InvalidArgumentType -from gvm.protocols.http.openvasd.scans import ScanAction, ScansAPI +from gvm.protocols.http.openvasd._scans import ScanAction, ScansAPI def _mock_response(status_code=200, json_data=None): @@ -20,7 +20,6 @@ def _mock_response(status_code=200, json_data=None): class TestScansAPI(unittest.TestCase): - def setUp(self): self.mock_client = MagicMock(spec=httpx.Client) self.api = ScansAPI(self.mock_client) diff --git a/tests/protocols/http/openvasd/test_vts.py b/tests/protocols/http/openvasd/test_vts.py index 0c9d2707..cb3dbf31 100644 --- a/tests/protocols/http/openvasd/test_vts.py +++ b/tests/protocols/http/openvasd/test_vts.py @@ -7,7 +7,7 @@ import httpx -from gvm.protocols.http.openvasd.vts import VtsAPI +from gvm.protocols.http.openvasd._vts import VtsAPI def _mock_response(status_code=200): @@ -18,7 +18,6 @@ def _mock_response(status_code=200): class TestVtsAPI(unittest.TestCase): - def setUp(self): self.mock_client = MagicMock(spec=httpx.Client) self.api = VtsAPI(self.mock_client) From 1df0864db0050857a0efd6a333695bedc1290cab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Tue, 8 Jul 2025 09:53:50 +0200 Subject: [PATCH 29/34] change: Require httpx as direct dependency for openvasd API --- poetry.lock | 70 +++++++++++++++++++++-------------------------------- 1 file changed, 28 insertions(+), 42 deletions(-) diff --git a/poetry.lock b/poetry.lock index faaffc9a..40811f7a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "alabaster" @@ -18,7 +18,7 @@ version = "4.9.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, @@ -32,7 +32,7 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] +test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] [[package]] @@ -114,7 +114,7 @@ files = [ ] [package.extras] -dev = ["backports.zoneinfo", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata"] +dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] [[package]] name = "bcrypt" @@ -257,7 +257,7 @@ version = "2025.6.15" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057"}, {file = "certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b"}, @@ -567,7 +567,7 @@ files = [ ] [package.extras] -toml = ["tomli"] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" @@ -620,10 +620,10 @@ files = [ cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""} [package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs ; python_full_version >= \"3.8.0\"", "sphinx-rtd-theme (>=3.0.0) ; python_full_version >= \"3.8.0\""] docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2)"] -pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_full_version >= \"3.8.0\""] +pep8test = ["check-sdist ; python_full_version >= \"3.8.0\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] test = ["certifi (>=2024)", "cryptography-vectors (==45.0.5)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] @@ -647,7 +647,7 @@ version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, @@ -704,7 +704,7 @@ version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -716,7 +716,7 @@ version = "4.2.0" description = "Pure-Python HTTP/2 protocol implementation" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "h2-4.2.0-py3-none-any.whl", hash = "sha256:479a53ad425bb29af087f3458a61d30780bc818e4ebcf01f0b536ba916462ed0"}, {file = "h2-4.2.0.tar.gz", hash = "sha256:c8a52129695e88b1a0578d8d2cc6842bbd79128ac685463b887ee278126ad01f"}, @@ -732,7 +732,7 @@ version = "4.1.0" description = "Pure-Python HPACK header encoding" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496"}, {file = "hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca"}, @@ -744,7 +744,7 @@ version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -766,7 +766,7 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -780,7 +780,7 @@ httpcore = "==1.*" idna = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -792,7 +792,7 @@ version = "6.1.0" description = "Pure-Python HTTP/2 framing" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"}, {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"}, @@ -804,7 +804,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -842,12 +842,12 @@ files = [ zipp = ">=3.20" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib_resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] type = ["pytest-mypy"] [[package]] @@ -1247,8 +1247,8 @@ cryptography = ">=3.3" pynacl = ">=1.5" [package.extras] -all = ["gssapi (>=1.4.1)", "invoke (>=2.0)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] -gssapi = ["gssapi (>=1.4.1)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] +all = ["gssapi (>=1.4.1) ; platform_system != \"Windows\"", "invoke (>=2.0)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8) ; platform_system == \"Windows\""] +gssapi = ["gssapi (>=1.4.1) ; platform_system != \"Windows\"", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8) ; platform_system == \"Windows\""] invoke = ["invoke (>=2.0)"] [[package]] @@ -1552,7 +1552,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -1807,32 +1807,18 @@ files = [ [package.dependencies] cryptography = ">=37.0.0" -[[package]] -name = "types-requests" -version = "2.32.0.20250328" -description = "Typing stubs for requests" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "types_requests-2.32.0.20250328-py3-none-any.whl", hash = "sha256:72ff80f84b15eb3aa7a8e2625fffb6a93f2ad5a0c20215fc1dcfa61117bcb2a2"}, - {file = "types_requests-2.32.0.20250328.tar.gz", hash = "sha256:c9e67228ea103bd811c96984fac36ed2ae8da87a36a633964a21f199d60baf32"}, -] - -[package.dependencies] -urllib3 = ">=2" - [[package]] name = "typing-extensions" version = "4.14.1" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, ] +markers = {main = "python_version < \"3.13\""} [[package]] name = "urllib3" @@ -1847,7 +1833,7 @@ files = [ ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -1866,7 +1852,7 @@ files = [ ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] @@ -1876,4 +1862,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9.2" -content-hash = "5b07223adfc7448bacbf31834a44d5ecdd010779a7f473fc34910c9e1a25bebe" +content-hash = "c4aea8eeef68616539cbf0c5393936a80b3565878a0367ed8a282390ea71c89c" From 1602d0e48959ffcd045d6c3a4dff218e9a3de301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Tue, 8 Jul 2025 11:09:31 +0200 Subject: [PATCH 30/34] Cleanup and improve the openvasd API Drop the safe argument from all methods and replace it with a instance variable. Improve the types and docstrings of all methods. --- gvm/protocols/http/openvasd/_api.py | 23 +++ gvm/protocols/http/openvasd/_health.py | 44 ++--- gvm/protocols/http/openvasd/_metadata.py | 35 ++-- gvm/protocols/http/openvasd/_notus.py | 30 ++-- gvm/protocols/http/openvasd/_openvasd1.py | 76 +++++++- gvm/protocols/http/openvasd/_scans.py | 113 ++++++------ gvm/protocols/http/openvasd/_vts.py | 29 +-- tests/protocols/http/openvasd/test_health.py | 40 +++-- .../protocols/http/openvasd/test_metadata.py | 23 ++- tests/protocols/http/openvasd/test_notus.py | 26 +-- tests/protocols/http/openvasd/test_scans.py | 166 +++++++++++------- tests/protocols/http/openvasd/test_vts.py | 25 ++- 12 files changed, 368 insertions(+), 262 deletions(-) create mode 100644 gvm/protocols/http/openvasd/_api.py diff --git a/gvm/protocols/http/openvasd/_api.py b/gvm/protocols/http/openvasd/_api.py new file mode 100644 index 00000000..22aea0b2 --- /dev/null +++ b/gvm/protocols/http/openvasd/_api.py @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + + +import httpx + + +class OpenvasdAPI: + def __init__( + self, client: httpx.Client, *, suppress_exceptions: bool = False + ): + """ + Initialize the OpenvasdAPI entry point. + + Args: + client: An initialized `httpx.Client` configured for communicating + with the openvasd server. + suppress_exceptions: If True, suppress exceptions and return structured error responses. + Default is False, which means exceptions will be raised. + """ + self._client = client + self._suppress_exceptions = suppress_exceptions diff --git a/gvm/protocols/http/openvasd/_health.py b/gvm/protocols/http/openvasd/_health.py index 231835dd..8701fa6c 100644 --- a/gvm/protocols/http/openvasd/_health.py +++ b/gvm/protocols/http/openvasd/_health.py @@ -8,8 +8,10 @@ import httpx +from ._api import OpenvasdAPI -class HealthAPI: + +class HealthAPI(OpenvasdAPI): """ Provides access to the openvasd /health endpoints, which expose the operational state of the scanner. @@ -18,28 +20,16 @@ class HealthAPI: if the server returns an error response (4xx or 5xx). """ - def __init__(self, client: httpx.Client): - """ - Create a new HealthAPI instance. - - Args: - client: An initialized `httpx.Client` configured for communicating - with the openvasd server. - """ - self._client = client - - def get_alive(self, safe: bool = False) -> int: + def get_alive(self) -> int: """ Check if the scanner process is alive. - Args: - safe: If True, suppress exceptions and return structured error responses. - Returns: HTTP status code (e.g., 200 if alive). Raises: - httpx.HTTPStatusError: If the server response indicates failure and safe is False. + httpx.HTTPStatusError: If the server response indicates failure and + exceptions are not suppressed. See: GET /health/alive in the openvasd API documentation. """ @@ -48,22 +38,20 @@ def get_alive(self, safe: bool = False) -> int: response.raise_for_status() return response.status_code except httpx.HTTPStatusError as e: - if safe: + if self._suppress_exceptions: return e.response.status_code raise - def get_ready(self, safe: bool = False) -> int: + def get_ready(self) -> int: """ Check if the scanner is ready to accept requests (e.g., feed loaded). - Args: - safe: If True, suppress exceptions and return structured error responses. - Returns: HTTP status code (e.g., 200 if ready). Raises: - httpx.HTTPStatusError: If the server response indicates failure and safe is False. + httpx.HTTPStatusError: If the server response indicates failure and + exceptions are not suppressed. See: GET /health/ready in the openvasd API documentation. """ @@ -72,22 +60,20 @@ def get_ready(self, safe: bool = False) -> int: response.raise_for_status() return response.status_code except httpx.HTTPStatusError as e: - if safe: + if self._suppress_exceptions: return e.response.status_code raise - def get_started(self, safe: bool = False) -> int: + def get_started(self) -> int: """ Check if the scanner has fully started. - Args: - safe: If True, suppress exceptions and return structured error responses. - Returns: HTTP status code (e.g., 200 if started). Raises: - httpx.HTTPStatusError: If the server response indicates failure and safe is False. + httpx.HTTPStatusError: If the server response indicates failure and + exceptions are not suppressed. See: GET /health/started in the openvasd API documentation. """ @@ -96,6 +82,6 @@ def get_started(self, safe: bool = False) -> int: response.raise_for_status() return response.status_code except httpx.HTTPStatusError as e: - if safe: + if self._suppress_exceptions: return e.response.status_code raise diff --git a/gvm/protocols/http/openvasd/_metadata.py b/gvm/protocols/http/openvasd/_metadata.py index a48c07aa..ad7ea130 100644 --- a/gvm/protocols/http/openvasd/_metadata.py +++ b/gvm/protocols/http/openvasd/_metadata.py @@ -6,10 +6,14 @@ API wrapper for retrieving metadata from the openvasd HTTP API using HEAD requests. """ +from typing import Union + import httpx +from ._api import OpenvasdAPI + -class MetadataAPI: +class MetadataAPI(OpenvasdAPI): """ Provides access to metadata endpoints exposed by the openvasd server using lightweight HTTP HEAD requests. @@ -23,22 +27,10 @@ class MetadataAPI: is handled gracefully. """ - def __init__(self, client: httpx.Client): - """ - Initialize a MetadataAPI instance. - - Args: - client: An `httpx.Client` configured to communicate with the openvasd server. - """ - self._client = client - - def get(self, safe: bool = False) -> dict: + def get(self) -> dict[str, Union[str, int]]: """ Perform a HEAD request to `/` to retrieve top-level API metadata. - Args: - safe: If True, suppress exceptions and return structured error responses. - Returns: A dictionary containing: @@ -46,12 +38,12 @@ def get(self, safe: bool = False) -> dict: - "feed-version" - "authentication" - Or if safe=True and error occurs: + Or if exceptions are suppressed and error occurs: - {"error": str, "status_code": int} Raises: - httpx.HTTPStatusError: For non-401 HTTP errors if safe=False. + httpx.HTTPStatusError: For non-401 HTTP errors if exceptions are not suppressed. See: HEAD / in the openvasd API documentation. """ @@ -64,17 +56,14 @@ def get(self, safe: bool = False) -> dict: "authentication": response.headers.get("authentication"), } except httpx.HTTPStatusError as e: - if safe: + if self._suppress_exceptions: return {"error": str(e), "status_code": e.response.status_code} raise - def get_scans(self, safe: bool = False) -> dict: + def get_scans(self) -> dict[str, Union[str, int]]: """ Perform a HEAD request to `/scans` to retrieve scan endpoint metadata. - Args: - safe: If True, suppress exceptions and return structured error responses. - Returns: A dictionary containing: @@ -87,7 +76,7 @@ def get_scans(self, safe: bool = False) -> dict: - {"error": str, "status_code": int} Raises: - httpx.HTTPStatusError: For non-401 HTTP errors if safe=False. + httpx.HTTPStatusError: For non-401 HTTP errors if exceptions are not suppressed. See: HEAD /scans in the openvasd API documentation. """ @@ -100,6 +89,6 @@ def get_scans(self, safe: bool = False) -> dict: "authentication": response.headers.get("authentication"), } except httpx.HTTPStatusError as e: - if safe: + if self._suppress_exceptions: return {"error": str(e), "status_code": e.response.status_code} raise diff --git a/gvm/protocols/http/openvasd/_notus.py b/gvm/protocols/http/openvasd/_notus.py index 098b5709..1af06750 100644 --- a/gvm/protocols/http/openvasd/_notus.py +++ b/gvm/protocols/http/openvasd/_notus.py @@ -6,13 +6,14 @@ API wrapper for interacting with the Notus component of the openvasd HTTP API. """ -import urllib.parse -from typing import List +import urllib import httpx +from ._api import OpenvasdAPI -class NotusAPI: + +class NotusAPI(OpenvasdAPI): """ Provides access to the Notus-related endpoints of the openvasd HTTP API. @@ -20,22 +21,10 @@ class NotusAPI: package-based vulnerability scans for a specific OS. """ - def __init__(self, client: httpx.Client): - """ - Initialize a NotusAPI instance. - - Args: - client: An `httpx.Client` configured to communicate with the openvasd server. - """ - self._client = client - - def get_os_list(self, safe: bool = False) -> httpx.Response: + def get_os_list(self) -> httpx.Response: """ Retrieve the list of supported operating systems from the Notus service. - Args: - safe: If True, return error info on failure instead of raising. - Returns: The full `httpx.Response` on success. @@ -46,12 +35,14 @@ def get_os_list(self, safe: bool = False) -> httpx.Response: response.raise_for_status() return response except httpx.HTTPStatusError as e: - if safe: + if self._suppress_exceptions: return e.response raise def run_scan( - self, os: str, package_list: List[str], safe: bool = False + self, + os: str, + package_list: list[str], ) -> httpx.Response: """ Trigger a Notus scan for a given OS and list of packages. @@ -59,7 +50,6 @@ def run_scan( Args: os: Operating system name (e.g., "debian", "alpine"). package_list: List of package names to evaluate for vulnerabilities. - safe: If True, return error info on failure instead of raising. Returns: The full `httpx.Response` on success. @@ -74,6 +64,6 @@ def run_scan( response.raise_for_status() return response except httpx.HTTPStatusError as e: - if safe: + if self._suppress_exceptions: return e.response raise diff --git a/gvm/protocols/http/openvasd/_openvasd1.py b/gvm/protocols/http/openvasd/_openvasd1.py index 23bcdf61..1793e5d3 100644 --- a/gvm/protocols/http/openvasd/_openvasd1.py +++ b/gvm/protocols/http/openvasd/_openvasd1.py @@ -39,6 +39,7 @@ def __init__( client_cert_paths: Optional[ Union[StrOrPathLike, Tuple[StrOrPathLike, StrOrPathLike]] ] = None, + suppress_exceptions: bool = False, ): """ Initialize the OpenvasdHttpApiV1 entry point. @@ -49,10 +50,8 @@ def __init__( api_key: Optional API key to be used for authentication. server_ca_path: Path to the server CA certificate (for HTTPS/mTLS). client_cert_paths: Path to client certificate or (cert, key) tuple for mTLS. - - Behavior: - - Sets up an underlying `httpx.Client` using `OpenvasdClient`. - - Initializes sub-API modules with the shared client instance. + suppress_exceptions: If True, suppress exceptions and return structured error + responses. Default is False, which means exceptions will be raised. """ self._client = create_openvasd_http_client( host_name=host_name, @@ -63,8 +62,67 @@ def __init__( ) # Sub-API modules - self.health = HealthAPI(self._client) - self.metadata = MetadataAPI(self._client) - self.notus = NotusAPI(self._client) - self.scans = ScansAPI(self._client) - self.vts = VtsAPI(self._client) + self.__health = HealthAPI( + self._client, suppress_exceptions=suppress_exceptions + ) + self.__metadata = MetadataAPI( + self._client, suppress_exceptions=suppress_exceptions + ) + self.__notus = NotusAPI( + self._client, suppress_exceptions=suppress_exceptions + ) + self.__scans = ScansAPI( + self._client, suppress_exceptions=suppress_exceptions + ) + self.__vts = VtsAPI( + self._client, suppress_exceptions=suppress_exceptions + ) + + @property + def health(self) -> HealthAPI: + """ + Access the health API module. + + Provides methods to check the health status of the openvasd service. + """ + return self.__health + + @property + def metadata(self) -> MetadataAPI: + """ + Access the metadata API module. + + Provides methods to retrieve metadata about the openvasd service, + including version and feed information. + """ + return self.__metadata + + @property + def notus(self) -> NotusAPI: + """ + Access the Notus API module. + + Provides methods to interact with the Notus service for package-based + vulnerability scanning. + """ + return self.__notus + + @property + def scans(self) -> ScansAPI: + """ + Access the scans API module. + + Provides methods to manage and interact with vulnerability scans, + including starting, stopping, and retrieving scan results. + """ + return self.__scans + + @property + def vts(self) -> VtsAPI: + """ + Access the VTS API module. + + Provides methods to manage and interact with Vulnerability Tests + (VTs) for vulnerability assessment. + """ + return self.__vts diff --git a/gvm/protocols/http/openvasd/_scans.py b/gvm/protocols/http/openvasd/_scans.py index 67d363e1..69129592 100644 --- a/gvm/protocols/http/openvasd/_scans.py +++ b/gvm/protocols/http/openvasd/_scans.py @@ -9,11 +9,16 @@ import urllib.parse from enum import Enum from typing import Any, Optional, Union +from uuid import UUID import httpx from gvm.errors import InvalidArgumentType +from ._api import OpenvasdAPI + +ID = Union[str, UUID] + class ScanAction(str, Enum): """ @@ -32,7 +37,7 @@ class ScanAction(str, Enum): STOP = "stop" # Stop the scan -class ScansAPI: +class ScansAPI(OpenvasdAPI): """ Provides access to scan-related operations in the openvasd HTTP API. @@ -40,21 +45,12 @@ class ScansAPI: and results. """ - def __init__(self, client: httpx.Client): - """ - Initialize a ScansAPI instance. - - Args: - client: An `httpx.Client` instance configured to talk to the openvasd server. - """ - self._client = client - def create( self, target: dict[str, Any], vt_selection: list[dict[str, Any]], + *, scanner_params: Optional[dict[str, Any]] = None, - safe: bool = False, ) -> httpx.Response: """ Create a new scan with the specified target and VT selection. @@ -63,13 +59,13 @@ def create( target: Dictionary describing the scan target (e.g., host and port). vt_selection: List of dictionaries specifying which VTs to run. scanner_params: Optional dictionary of scan preferences. - safe: If True, suppress exceptions and return structured error responses. Returns: The full HTTP response of the POST /scans request. Raises: - httpx.HTTPStatusError: If the server responds with an error status. + httpx.HTTPStatusError: If the server responds with an error status + and the exception is not suppressed. See: POST /scans in the openvasd API documentation. """ @@ -82,50 +78,47 @@ def create( response.raise_for_status() return response except httpx.HTTPStatusError as e: - if safe: + if self._suppress_exceptions: return e.response raise - def delete(self, scan_id: str, safe: bool = False) -> int: + def delete(self, scan_id: ID) -> int: """ Delete a scan by its ID. Args: scan_id: The scan identifier. - safe: If True, suppress exceptions and return structured error responses. Returns: The HTTP status code returned by the server on success, or the error status code returned by the server on failure. Raises: - Raise exceptions if safe is False; HTTP errors are caught and the corresponding status code is returned. + httpx.HTTPStatusError: If the server responds with an error status + and the exception is not suppressed. See: DELETE /scans/{id} in the openvasd API documentation. """ try: response = self._client.delete( - f"/scans/{urllib.parse.quote(scan_id)}" + f"/scans/{urllib.parse.quote(str(scan_id))}" ) response.raise_for_status() return response.status_code except httpx.HTTPStatusError as e: - if safe: + if self._suppress_exceptions: return e.response.status_code raise - def get_all(self, safe: bool = False) -> httpx.Response: + def get_all(self) -> httpx.Response: """ Retrieve the list of all available scans. - Args: - safe: If True, suppress exceptions and return structured error responses. - Returns: The full HTTP response of the GET /scans request. Raises: - httpx.HTTPStatusError: If the request fails. + httpx.HTTPStatusError: If the request fails and exceptions are not suppressed. See: GET /scans in the openvasd API documentation. """ @@ -134,41 +127,42 @@ def get_all(self, safe: bool = False) -> httpx.Response: response.raise_for_status() return response except httpx.HTTPStatusError as e: - if safe: + if self._suppress_exceptions: return e.response raise - def get(self, scan_id: str, safe: bool = False) -> httpx.Response: + def get(self, scan_id: ID) -> httpx.Response: """ Retrieve metadata of a single scan by ID. Args: scan_id: The scan identifier. - safe: If True, suppress exceptions and return structured error responses. Returns: The full HTTP response of the GET /scans/{id} request. Raises: - httpx.HTTPStatusError: If the request fails. + httpx.HTTPStatusError: If the request fails and exceptions are not suppressed. See: GET /scans/{id} in the openvasd API documentation. """ try: - response = self._client.get(f"/scans/{urllib.parse.quote(scan_id)}") + response = self._client.get( + f"/scans/{urllib.parse.quote(str(scan_id))}" + ) response.raise_for_status() return response except httpx.HTTPStatusError as e: - if safe: + if self._suppress_exceptions: return e.response raise def get_results( self, - scan_id: str, + scan_id: ID, + *, range_start: Optional[int] = None, range_end: Optional[int] = None, - safe: bool = False, ) -> httpx.Response: """ Retrieve a range of results for a given scan. @@ -177,14 +171,13 @@ def get_results( scan_id: The scan identifier. range_start: Optional start index for paginated results. range_end: Optional end index for paginated results. - safe: If True, suppress exceptions and return structured error responses. Returns: The full HTTP response of the GET /scans/{id}/results request. Raises: InvalidArgumentType: If provided range values are not integers. - httpx.HTTPStatusError: If the request fails. + httpx.HTTPStatusError: If the request fails and exceptions are not suppressed. See: GET /scans/{id}/results in the openvasd API documentation. """ @@ -205,17 +198,20 @@ def get_results( try: response = self._client.get( - f"/scans/{urllib.parse.quote(scan_id)}/results", params=params + f"/scans/{urllib.parse.quote(str(scan_id))}/results", + params=params, ) response.raise_for_status() return response except httpx.HTTPStatusError as e: - if safe: + if self._suppress_exceptions: return e.response raise def get_result( - self, scan_id: str, result_id: Union[str, int], safe: bool = False + self, + scan_id: ID, + result_id: ID, ) -> httpx.Response: """ Retrieve a specific scan result. @@ -223,114 +219,113 @@ def get_result( Args: scan_id: The scan identifier. result_id: The specific result ID to fetch. - safe: If True, suppress exceptions and return structured error responses. Returns: The full HTTP response of the GET /scans/{id}/results/{rid} request. Raises: - httpx.HTTPStatusError: If the request fails. + httpx.HTTPStatusError: If the request fails and exceptions are not suppressed. See: GET /scans/{id}/results/{rid} in the openvasd API documentation. """ try: response = self._client.get( - f"/scans/{urllib.parse.quote(scan_id)}/results/{urllib.parse.quote(str(result_id))}" + f"/scans/{urllib.parse.quote(str(scan_id))}/results/{urllib.parse.quote(str(result_id))}" ) response.raise_for_status() return response except httpx.HTTPStatusError as e: - if safe: + if self._suppress_exceptions: return e.response raise - def get_status(self, scan_id: str, safe: bool = False) -> httpx.Response: + def get_status(self, scan_id: ID) -> httpx.Response: """ Retrieve the status of a scan. Args: scan_id: The scan identifier. - safe: If True, suppress exceptions and return structured error responses. Returns: The full HTTP response of the GET /scans/{id}/status request. Raises: - httpx.HTTPStatusError: If the request fails. + httpx.HTTPStatusError: If the request fails and exceptions are not suppressed. See: GET /scans/{id}/status in the openvasd API documentation. """ try: response = self._client.get( - f"/scans/{urllib.parse.quote(scan_id)}/status" + f"/scans/{urllib.parse.quote(str(scan_id))}/status" ) response.raise_for_status() return response except httpx.HTTPStatusError as e: - if safe: + if self._suppress_exceptions: return e.response raise - def run_action( - self, scan_id: str, action: ScanAction, safe: bool = False - ) -> int: + def _run_action(self, scan_id: ID, action: ScanAction) -> int: """ Perform a scan action (start or stop) for the given scan ID. Args: scan_id: The unique identifier of the scan. action: A member of the ScanAction enum (e.g., ScanAction.START or ScanAction.STOP). - safe: If True, suppress exceptions and return structured error responses. Returns: The HTTP status code returned by the server on success, or the error status code returned by the server on failure. Raises: - Does not raise exceptions; HTTP errors are caught and the corresponding status code is returned. + httpx.HTTPStatusError: If the request fails and exceptions are not suppressed. See: POST /scans/{id} in the openvasd API documentation. """ try: response = self._client.post( - f"/scans/{urllib.parse.quote(scan_id)}", + f"/scans/{urllib.parse.quote(str(scan_id))}", json={"action": str(action.value)}, ) response.raise_for_status() return response.status_code except httpx.HTTPStatusError as e: - if safe: + if self._suppress_exceptions: return e.response.status_code raise - def start(self, scan_id: str, safe: bool = False) -> int: + def start(self, scan_id: ID) -> int: """ Start the scan identified by the given scan ID. Args: scan_id: The unique identifier of the scan. - safe: If True, suppress exceptions and return structured error responses. Returns: HTTP status code (e.g., 200) if successful, or error code (e.g., 404, 500) if the request fails, and safe is False. + Raises: + httpx.HTTPStatusError: If the request fails and exceptions are not suppressed. + See: POST /scans/{id} with action=start in the openvasd API documentation. """ - return self.run_action(scan_id, ScanAction.START, safe) + return self._run_action(scan_id, ScanAction.START) - def stop(self, scan_id: str, safe: bool = False) -> int: + def stop(self, scan_id: ID) -> int: """ Stop the scan identified by the given scan ID. Args: scan_id: The unique identifier of the scan. - safe: If True, suppress exceptions and return structured error responses. Returns: HTTP status code (e.g., 200) if successful, or error code (e.g., 404, 500) if the request fails, and safe is False. + Raises: + httpx.HTTPStatusError: If the request fails and exceptions are not suppressed. + See: POST /scans/{id} with action=stop in the openvasd API documentation. """ - return self.run_action(scan_id, ScanAction.STOP, safe) + return self._run_action(scan_id, ScanAction.STOP) diff --git a/gvm/protocols/http/openvasd/_vts.py b/gvm/protocols/http/openvasd/_vts.py index a28fb956..1753ac63 100644 --- a/gvm/protocols/http/openvasd/_vts.py +++ b/gvm/protocols/http/openvasd/_vts.py @@ -10,8 +10,10 @@ import httpx +from ._api import OpenvasdAPI -class VtsAPI: + +class VtsAPI(OpenvasdAPI): """ Provides access to the openvasd /vts endpoints. @@ -19,29 +21,17 @@ class VtsAPI: as well as fetching detailed information for individual VTs by OID. """ - def __init__(self, client: httpx.Client): - """ - Initialize a VtsAPI instance. - - Args: - client: An `httpx.Client` instance configured to communicate with the openvasd server. - """ - self._client = client - - def get_all(self, safe: bool = False) -> httpx.Response: + def get_all(self) -> httpx.Response: """ Retrieve the list of all available vulnerability tests (VTs). This corresponds to a GET request to `/vts`. - Args: - safe: If True, suppress exceptions and return structured error responses. - Returns: The full `httpx.Response` containing a JSON list of VT entries. Raises: - httpx.HTTPStatusError: If the server returns a non-success status and safe is False. + httpx.HTTPStatusError: If the server returns a non-success status and exceptions are not suppressed. See: GET /vts in the openvasd API documentation. """ @@ -50,11 +40,11 @@ def get_all(self, safe: bool = False) -> httpx.Response: response.raise_for_status() return response except httpx.HTTPStatusError as e: - if safe: + if self._suppress_exceptions: return e.response raise - def get(self, oid: str, safe: bool = False) -> httpx.Response: + def get(self, oid: str) -> httpx.Response: """ Retrieve detailed information about a specific VT by OID. @@ -62,13 +52,12 @@ def get(self, oid: str, safe: bool = False) -> httpx.Response: Args: oid: The OID (object identifier) of the vulnerability test. - safe: If True, suppress exceptions and return structured error responses. Returns: The full `httpx.Response` containing VT metadata for the given OID. Raises: - httpx.HTTPStatusError: If the server returns a non-success status and safe is False. + httpx.HTTPStatusError: If the server returns a non-success status and exceptions are not suppressed. See: GET /vts/{id} in the openvasd API documentation. """ @@ -78,6 +67,6 @@ def get(self, oid: str, safe: bool = False) -> httpx.Response: response.raise_for_status() return response except httpx.HTTPStatusError as e: - if safe: + if self._suppress_exceptions: return e.response raise diff --git a/tests/protocols/http/openvasd/test_health.py b/tests/protocols/http/openvasd/test_health.py index 99676e4f..c2c202b7 100644 --- a/tests/protocols/http/openvasd/test_health.py +++ b/tests/protocols/http/openvasd/test_health.py @@ -20,71 +20,84 @@ def _mock_response(status_code=200): class TestHealthAPI(unittest.TestCase): def setUp(self): self.mock_client = MagicMock(spec=httpx.Client) - self.health_api = HealthAPI(self.mock_client) def test_alive_returns_status_code(self): mock_response = _mock_response(200) self.mock_client.get.return_value = mock_response - result = self.health_api.get_alive() + health_api = HealthAPI(self.mock_client) + result = health_api.get_alive() self.mock_client.get.assert_called_once_with("/health/alive") mock_response.raise_for_status.assert_called_once() self.assertEqual(result, 200) - def test_alive_raises_httpx_error_returns_503_with_safe_true(self): + def test_alive_raises_httpx_error_returns_503_with_suppress_exceptions( + self, + ): self.mock_client.get.side_effect = httpx.HTTPStatusError( "Not OK", request=MagicMock(), response=MagicMock(status_code=503) ) - result = self.health_api.get_alive(safe=True) + health_api = HealthAPI(self.mock_client, suppress_exceptions=True) + result = health_api.get_alive() self.assertEqual(result, 503) def test_alive_raises_httpx_error(self): self.mock_client.get.side_effect = httpx.HTTPStatusError( "Not OK", request=MagicMock(), response=MagicMock(status_code=503) ) + health_api = HealthAPI(self.mock_client) with self.assertRaises(httpx.HTTPStatusError): - self.health_api.get_alive() + health_api.get_alive() def test_ready_returns_status_code(self): mock_response = _mock_response(204) self.mock_client.get.return_value = mock_response - result = self.health_api.get_ready() + health_api = HealthAPI(self.mock_client) + result = health_api.get_ready() self.mock_client.get.assert_called_once_with("/health/ready") mock_response.raise_for_status.assert_called_once() self.assertEqual(result, 204) - def test_ready_raises_httpx_error_returns_503_with_safe_true(self): + def test_ready_raises_httpx_error_returns_503_with_suppress_exceptions( + self, + ): self.mock_client.get.side_effect = httpx.HTTPStatusError( "Not OK", request=MagicMock(), response=MagicMock(status_code=503) ) - result = self.health_api.get_ready(safe=True) + health_api = HealthAPI(self.mock_client, suppress_exceptions=True) + result = health_api.get_ready() self.assertEqual(result, 503) def test_ready_raises_httpx_error(self): self.mock_client.get.side_effect = httpx.HTTPStatusError( "Not OK", request=MagicMock(), response=MagicMock(status_code=503) ) + health_api = HealthAPI(self.mock_client) with self.assertRaises(httpx.HTTPStatusError): - self.health_api.get_ready() + health_api.get_ready() def test_started_returns_status_code(self): mock_response = _mock_response(202) self.mock_client.get.return_value = mock_response - result = self.health_api.get_started() + health_api = HealthAPI(self.mock_client) + result = health_api.get_started() self.mock_client.get.assert_called_once_with("/health/started") mock_response.raise_for_status.assert_called_once() self.assertEqual(result, 202) - def test_started_raises_httpx_error_returns_503_with_safe_true(self): + def test_started_raises_httpx_error_returns_503_with_suppress_exceptions( + self, + ): self.mock_client.get.side_effect = httpx.HTTPStatusError( "Not OK", request=MagicMock(), response=MagicMock(status_code=503) ) - result = self.health_api.get_started(safe=True) + health_api = HealthAPI(self.mock_client, suppress_exceptions=True) + result = health_api.get_started() self.assertEqual(result, 503) def test_started_raises_httpx_error(self): @@ -92,5 +105,6 @@ def test_started_raises_httpx_error(self): "Not OK", request=MagicMock(), response=MagicMock(status_code=503) ) + health_api = HealthAPI(self.mock_client) with self.assertRaises(httpx.HTTPStatusError): - self.health_api.get_started() + health_api.get_started() diff --git a/tests/protocols/http/openvasd/test_metadata.py b/tests/protocols/http/openvasd/test_metadata.py index f1a0e8ee..0ffe315c 100644 --- a/tests/protocols/http/openvasd/test_metadata.py +++ b/tests/protocols/http/openvasd/test_metadata.py @@ -21,7 +21,6 @@ def _mock_head_response(status_code=200, headers=None): class TestMetadataAPI(unittest.TestCase): def setUp(self): self.mock_client = MagicMock(spec=httpx.Client) - self.api = MetadataAPI(self.mock_client) def test_get_successful(self): headers = { @@ -32,12 +31,13 @@ def test_get_successful(self): mock_response = _mock_head_response(200, headers) self.mock_client.head.return_value = mock_response - result = self.api.get() + api = MetadataAPI(self.mock_client) + result = api.get() self.mock_client.head.assert_called_once_with("/") mock_response.raise_for_status.assert_called_once() self.assertEqual(result, headers) - def test_get_unauthorized_with_safe_true(self): + def test_get_unauthorized_with_suppress_exceptions(self): mock_response = MagicMock(spec=httpx.Response) mock_response.status_code = 401 @@ -45,7 +45,8 @@ def test_get_unauthorized_with_safe_true(self): "Unauthorized", request=MagicMock(), response=mock_response ) - result = self.api.get(safe=True) + api = MetadataAPI(self.mock_client, suppress_exceptions=True) + result = api.get() self.assertEqual(result, {"error": "Unauthorized", "status_code": 401}) def test_get_failure_raises_httpx_error(self): @@ -57,8 +58,9 @@ def test_get_failure_raises_httpx_error(self): ) self.mock_client.head.return_value = mock_response + api = MetadataAPI(self.mock_client) with self.assertRaises(httpx.HTTPStatusError): - self.api.get() + api.get() self.mock_client.head.assert_called_once_with("/") @@ -71,12 +73,13 @@ def test_get_scans_successful(self): mock_response = _mock_head_response(200, headers) self.mock_client.head.return_value = mock_response - result = self.api.get_scans() + api = MetadataAPI(self.mock_client) + result = api.get_scans() self.mock_client.head.assert_called_once_with("/scans") mock_response.raise_for_status.assert_called_once() self.assertEqual(result, headers) - def test_get_scans_unauthorized_with_safe_true(self): + def test_get_scans_unauthorized_with_suppress_exceptions(self): mock_response = MagicMock(spec=httpx.Response) mock_response.status_code = 401 @@ -84,7 +87,8 @@ def test_get_scans_unauthorized_with_safe_true(self): "Unauthorized", request=MagicMock(), response=mock_response ) - result = self.api.get_scans(safe=True) + api = MetadataAPI(self.mock_client, suppress_exceptions=True) + result = api.get_scans() self.assertEqual(result, {"error": "Unauthorized", "status_code": 401}) def test_get_scans_failure_raises_httpx_error(self): @@ -96,7 +100,8 @@ def test_get_scans_failure_raises_httpx_error(self): ) self.mock_client.head.return_value = mock_response + api = MetadataAPI(self.mock_client) with self.assertRaises(httpx.HTTPStatusError): - self.api.get_scans() + api.get_scans() self.mock_client.head.assert_called_once_with("/scans") diff --git a/tests/protocols/http/openvasd/test_notus.py b/tests/protocols/http/openvasd/test_notus.py index 66471e63..72cc63b5 100644 --- a/tests/protocols/http/openvasd/test_notus.py +++ b/tests/protocols/http/openvasd/test_notus.py @@ -21,18 +21,18 @@ def _mock_response(status_code=200, json_data=None): class TestNotusAPI(unittest.TestCase): def setUp(self): self.mock_client = MagicMock(spec=httpx.Client) - self.api = NotusAPI(self.mock_client) def test_get_os_list_success(self): mock_response = _mock_response(json_data=["debian", "alpine"]) self.mock_client.get.return_value = mock_response - result = self.api.get_os_list() + api = NotusAPI(self.mock_client) + result = api.get_os_list() self.mock_client.get.assert_called_once_with("/notus") mock_response.raise_for_status.assert_called_once() self.assertEqual(result, mock_response) - def test_get_os_list_http_error_with_safe_true(self): + def test_get_os_list_http_error_with_suppress_exceptions(self): mock_response = MagicMock() mock_response.status_code = 500 @@ -40,7 +40,8 @@ def test_get_os_list_http_error_with_safe_true(self): "Internal Server Error", request=MagicMock(), response=mock_response ) - result = self.api.get_os_list(safe=True) + api = NotusAPI(self.mock_client, suppress_exceptions=True) + result = api.get_os_list() self.assertEqual(result, mock_response) @@ -49,14 +50,16 @@ def test_get_os_list_http_error(self): "Error", request=MagicMock(), response=MagicMock(status_code=500) ) + api = NotusAPI(self.mock_client) with self.assertRaises(httpx.HTTPStatusError): - self.api.get_os_list() + api.get_os_list() def test_run_scan_success(self): mock_response = _mock_response(json_data={"vulnerabilities": []}) self.mock_client.post.return_value = mock_response - result = self.api.run_scan("debian", ["openssl", "bash"]) + api = NotusAPI(self.mock_client) + result = api.run_scan("debian", ["openssl", "bash"]) self.mock_client.post.assert_called_once_with( "/notus/debian", json=["openssl", "bash"] ) @@ -69,7 +72,8 @@ def test_run_scan_with_special_os_name(self): os_name = "alpine linux" encoded = "alpine%20linux" - self.api.run_scan(os_name, ["musl"]) + api = NotusAPI(self.mock_client) + api.run_scan(os_name, ["musl"]) self.mock_client.post.assert_called_once_with( f"/notus/{encoded}", json=["musl"] ) @@ -79,16 +83,18 @@ def test_run_scan_http_error(self): "Error", request=MagicMock(), response=MagicMock(status_code=400) ) + api = NotusAPI(self.mock_client) with self.assertRaises(httpx.HTTPStatusError): - self.api.run_scan("ubuntu", ["curl"]) + api.run_scan("ubuntu", ["curl"]) - def test_run_scan_http_error_with_safe_true(self): + def test_run_scan_http_error_with_suppress_exceptions(self): mock_response = MagicMock() mock_response.status_code = 500 self.mock_client.post.side_effect = httpx.HTTPStatusError( "Internal Server Error", request=MagicMock(), response=mock_response ) - response = self.api.run_scan("ubuntu", ["curl"], safe=True) + api = NotusAPI(self.mock_client, suppress_exceptions=True) + response = api.run_scan("ubuntu", ["curl"]) self.assertEqual(response, mock_response) diff --git a/tests/protocols/http/openvasd/test_scans.py b/tests/protocols/http/openvasd/test_scans.py index 9bd3de9e..de5f7f87 100644 --- a/tests/protocols/http/openvasd/test_scans.py +++ b/tests/protocols/http/openvasd/test_scans.py @@ -22,13 +22,15 @@ def _mock_response(status_code=200, json_data=None): class TestScansAPI(unittest.TestCase): def setUp(self): self.mock_client = MagicMock(spec=httpx.Client) - self.api = ScansAPI(self.mock_client) def test_create_scan_success(self): mock_resp = _mock_response() self.mock_client.post.return_value = mock_resp - self.api.create({"host": "localhost"}, [{"id": "1"}], {"threads": 4}) + api = ScansAPI(self.mock_client) + api.create( + {"host": "localhost"}, [{"id": "1"}], scanner_params={"threads": 4} + ) self.mock_client.post.assert_called_once_with( "/scans", json={ @@ -42,7 +44,8 @@ def test_create_scan_without_scan_preferences(self): mock_resp = _mock_response() self.mock_client.post.return_value = mock_resp - self.api.create({"host": "localhost"}, [{"id": "1"}]) + api = ScansAPI(self.mock_client) + api.create({"host": "localhost"}, [{"id": "1"}]) self.mock_client.post.assert_called_once_with( "/scans", @@ -58,8 +61,11 @@ def test_create_scan_failure_raises_httpx_error(self): ) self.mock_client.post.return_value = mock_response + api = ScansAPI(self.mock_client) with self.assertRaises(httpx.HTTPStatusError): - self.api.create({"host": "localhost"}, [{"id": "1"}], {"test": 4}) + api.create( + {"host": "localhost"}, [{"id": "1"}], scanner_params={"test": 4} + ) self.mock_client.post.assert_called_once_with( "/scans", @@ -70,7 +76,7 @@ def test_create_scan_failure_raises_httpx_error(self): }, ) - def test_create_scan_failure_httpx_error_with_safe_true(self): + def test_create_scan_failure_httpx_error_with_suppress_exceptions(self): mock_response = _mock_response() mock_http_error = httpx.HTTPStatusError( "Bad Request", @@ -80,8 +86,9 @@ def test_create_scan_failure_httpx_error_with_safe_true(self): mock_response.raise_for_status.side_effect = mock_http_error self.mock_client.post.return_value = mock_response - response = self.api.create( - {"host": "localhost"}, [{"id": "1"}], {"test": 4}, safe=True + api = ScansAPI(self.mock_client, suppress_exceptions=True) + response = api.create( + {"host": "localhost"}, [{"id": "1"}], scanner_params={"test": 4} ) self.mock_client.post.assert_called_once_with( @@ -100,10 +107,13 @@ def test_delete_scan_success(self): mock_resp = _mock_response(status_code=204) self.mock_client.delete.return_value = mock_resp - result = self.api.delete("scan-123") + api = ScansAPI(self.mock_client) + result = api.delete("scan-123") self.assertEqual(result, 204) - def test_delete_scan_returns_500_on_httpx_error_with_safe_true(self): + def test_delete_scan_returns_500_on_httpx_error_with_suppress_exceptions( + self, + ): mock_response = MagicMock(spec=httpx.Response) mock_response.status_code = 500 @@ -111,7 +121,8 @@ def test_delete_scan_returns_500_on_httpx_error_with_safe_true(self): "failed", request=MagicMock(), response=mock_response ) - status = self.api.delete("scan-1", True) + api = ScansAPI(self.mock_client, suppress_exceptions=True) + status = api.delete("scan-1") self.assertEqual(status, 500) def test_delete_scan_raise_on_httpx_error(self): @@ -122,14 +133,16 @@ def test_delete_scan_raise_on_httpx_error(self): "failed", request=MagicMock(), response=mock_response ) + api = ScansAPI(self.mock_client) with self.assertRaises(httpx.HTTPStatusError): - self.api.delete("scan-1") + api.delete("scan-1") def test_get_all_scans(self): mock_resp = _mock_response() self.mock_client.get.return_value = mock_resp - result = self.api.get_all() + api = ScansAPI(self.mock_client) + result = api.get_all() self.mock_client.get.assert_called_once_with("/scans") self.assertEqual(result, mock_resp) @@ -142,12 +155,13 @@ def test_get_all_scans_failure_raises_httpx_error(self): ) self.mock_client.get.return_value = mock_response + api = ScansAPI(self.mock_client) with self.assertRaises(httpx.HTTPStatusError): - self.api.get_all() + api.get_all() self.mock_client.get.assert_called_once_with("/scans") - def test_get_all_scans_failure_returns_500_with_safe_true(self): + def test_get_all_scans_failure_returns_500_with_suppress_exceptions(self): mock_response = _mock_response() mock_http_error = httpx.HTTPStatusError( "Server failed", @@ -157,7 +171,8 @@ def test_get_all_scans_failure_returns_500_with_safe_true(self): mock_response.raise_for_status.side_effect = mock_http_error self.mock_client.get.return_value = mock_response - response = self.api.get_all(safe=True) + api = ScansAPI(self.mock_client, suppress_exceptions=True) + response = api.get_all() self.mock_client.get.assert_called_once_with("/scans") self.assertEqual(response, mock_http_error.response) @@ -167,7 +182,8 @@ def test_get_scan_by_id(self): mock_resp = _mock_response() self.mock_client.get.return_value = mock_resp - result = self.api.get("scan-1") + api = ScansAPI(self.mock_client) + result = api.get("scan-1") self.mock_client.get.assert_called_once_with("/scans/scan-1") self.assertEqual(result, mock_resp) @@ -180,12 +196,15 @@ def test_get_scan_by_id_failure_raises_httpx_error(self): ) self.mock_client.get.return_value = mock_response + api = ScansAPI(self.mock_client) with self.assertRaises(httpx.HTTPStatusError): - self.api.get("scan-1") + api.get("scan-1") self.mock_client.get.assert_called_once_with("/scans/scan-1") - def test_get_scan_by_id_failure_return_500_httpx_error_with_safe_true(self): + def test_get_scan_by_id_failure_return_500_httpx_error_with_suppress_exceptions( + self, + ): mock_response = _mock_response() mock_http_error = httpx.HTTPStatusError( "Server failed", @@ -195,7 +214,8 @@ def test_get_scan_by_id_failure_return_500_httpx_error_with_safe_true(self): mock_response.raise_for_status.side_effect = mock_http_error self.mock_client.get.return_value = mock_response - response = self.api.get("scan-1", safe=True) + api = ScansAPI(self.mock_client, suppress_exceptions=True) + response = api.get("scan-1") self.mock_client.get.assert_called_once_with("/scans/scan-1") self.assertEqual(response, mock_http_error.response) @@ -205,7 +225,8 @@ def test_get_results_with_range(self): mock_resp = _mock_response() self.mock_client.get.return_value = mock_resp - self.api.get_results("scan-1", 5, 10) + api = ScansAPI(self.mock_client) + api.get_results("scan-1", range_start=5, range_end=10) self.mock_client.get.assert_called_once_with( "/scans/scan-1/results", params={"range": "5-10"} ) @@ -214,7 +235,8 @@ def test_get_results_only_range_start(self): mock_response = _mock_response() self.mock_client.get.return_value = mock_response - result = self.api.get_results("scan-1", range_start=7) + api = ScansAPI(self.mock_client) + result = api.get_results("scan-1", range_start=7) self.mock_client.get.assert_called_once_with( "/scans/scan-1/results", params={"range": "7"} ) @@ -224,7 +246,8 @@ def test_get_results_only_range_end(self): mock_response = _mock_response() self.mock_client.get.return_value = mock_response - result = self.api.get_results("scan-1", range_end=9) + api = ScansAPI(self.mock_client) + result = api.get_results("scan-1", range_end=9) self.mock_client.get.assert_called_once_with( "/scans/scan-1/results", params={"range": "0-9"} ) @@ -234,23 +257,27 @@ def test_get_results_without_range(self): mock_response = _mock_response() self.mock_client.get.return_value = mock_response - result = self.api.get_results("scan-1") + api = ScansAPI(self.mock_client) + result = api.get_results("scan-1") self.mock_client.get.assert_called_once_with( "/scans/scan-1/results", params={} ) self.assertEqual(result, mock_response) def test_get_results_invalid_range_type_for_range_start(self): + api = ScansAPI(self.mock_client) with self.assertRaises(InvalidArgumentType): - self.api.get_results("scan-1", "wrong", 5) + api.get_results("scan-1", range_start="wrong", range_end=5) def test_get_results_invalid_range_type_for_range_end(self): + api = ScansAPI(self.mock_client) with self.assertRaises(InvalidArgumentType): - self.api.get_results("scan-1", 5, "wrong") + api.get_results("scan-1", range_start=5, range_end="wrong") def test_get_results_invalid_range_end_type_only(self): + api = ScansAPI(self.mock_client) with self.assertRaises(InvalidArgumentType): - self.api.get_results("scan-1", range_end="not-an-int") + api.get_results("scan-1", range_end="not-an-int") def test_get_results_with_range_failure_raises_httpx_error(self): mock_response = _mock_response() @@ -261,14 +288,15 @@ def test_get_results_with_range_failure_raises_httpx_error(self): ) self.mock_client.get.return_value = mock_response + api = ScansAPI(self.mock_client) with self.assertRaises(httpx.HTTPStatusError): - self.api.get_results("scan-1", 5, 10) + api.get_results("scan-1", range_start=5, range_end=10) self.mock_client.get.assert_called_once_with( "/scans/scan-1/results", params={"range": "5-10"} ) - def test_get_results_with_range_failure_return_500_httpx_error_with_safe_true( + def test_get_results_with_range_failure_return_500_httpx_error_with_suppress_exceptions( self, ): mock_response = _mock_response() @@ -280,7 +308,8 @@ def test_get_results_with_range_failure_return_500_httpx_error_with_safe_true( mock_response.raise_for_status.side_effect = mock_http_error self.mock_client.get.return_value = mock_response - response = self.api.get_results("scan-1", 5, 10, safe=True) + api = ScansAPI(self.mock_client, suppress_exceptions=True) + response = api.get_results("scan-1", range_start=5, range_end=10) self.mock_client.get.assert_called_once_with( "/scans/scan-1/results", params={"range": "5-10"} @@ -292,7 +321,8 @@ def test_get_result(self): mock_resp = _mock_response() self.mock_client.get.return_value = mock_resp - self.api.get_result("scan-1", 99) + api = ScansAPI(self.mock_client) + api.get_result("scan-1", "99") self.mock_client.get.assert_called_once_with("/scans/scan-1/results/99") def test_get_result_failure_raises_httpx_error(self): @@ -304,12 +334,15 @@ def test_get_result_failure_raises_httpx_error(self): ) self.mock_client.get.return_value = mock_response + api = ScansAPI(self.mock_client) with self.assertRaises(httpx.HTTPStatusError): - self.api.get_result("scan-1", 99) + api.get_result("scan-1", "99") self.mock_client.get.assert_called_once_with("/scans/scan-1/results/99") - def test_get_result_failure_return_500_httpx_error_with_safe_true(self): + def test_get_result_failure_return_500_httpx_error_with_suppress_exceptions( + self, + ): mock_response = _mock_response() mock_http_error = httpx.HTTPStatusError( "Server failed", @@ -319,7 +352,8 @@ def test_get_result_failure_return_500_httpx_error_with_safe_true(self): mock_response.raise_for_status.side_effect = mock_http_error self.mock_client.get.return_value = mock_response - response = self.api.get_result("scan-1", 99, safe=True) + api = ScansAPI(self.mock_client, suppress_exceptions=True) + response = api.get_result("scan-1", "99") self.mock_client.get.assert_called_once_with("/scans/scan-1/results/99") self.assertEqual(response, mock_http_error.response) @@ -329,7 +363,8 @@ def test_get_status(self): mock_resp = _mock_response() self.mock_client.get.return_value = mock_resp - self.api.get_status("scan-1") + api = ScansAPI(self.mock_client) + api.get_status("scan-1") self.mock_client.get.assert_called_once_with("/scans/scan-1/status") def test_get_status_failure_raises_httpx_error(self): @@ -341,12 +376,15 @@ def test_get_status_failure_raises_httpx_error(self): ) self.mock_client.get.return_value = mock_response + api = ScansAPI(self.mock_client) with self.assertRaises(httpx.HTTPStatusError): - self.api.get_status("scan-1") + api.get_status("scan-1") self.mock_client.get.assert_called_once_with("/scans/scan-1/status") - def test_get_status_failure_return_500_httpx_error_with_safe_true(self): + def test_get_status_failure_return_500_httpx_error_with_suppress_exceptions( + self, + ): mock_response = _mock_response() mock_http_error = httpx.HTTPStatusError( "Server failed", @@ -356,7 +394,8 @@ def test_get_status_failure_return_500_httpx_error_with_safe_true(self): mock_response.raise_for_status.side_effect = mock_http_error self.mock_client.get.return_value = mock_response - response = self.api.get_status("scan-1", safe=True) + api = ScansAPI(self.mock_client, suppress_exceptions=True) + response = api.get_status("scan-1") self.mock_client.get.assert_called_once_with("/scans/scan-1/status") self.assertEqual(response, mock_http_error.response) @@ -366,34 +405,37 @@ def test_run_action_success(self): mock_resp = _mock_response(status_code=202) self.mock_client.post.return_value = mock_resp - status = self.api.run_action("scan-1", ScanAction.START) + api = ScansAPI(self.mock_client) + status = api._run_action("scan-1", ScanAction.START) self.assertEqual(status, 202) def test_run_action_failure_raise_httpx_error(self): self.mock_client.post.side_effect = httpx.HTTPStatusError( "failed", request=MagicMock(), response=MagicMock(status_code=500) ) + api = ScansAPI(self.mock_client) with self.assertRaises(httpx.HTTPStatusError): - self.api.run_action("scan-1", ScanAction.STOP) + api._run_action("scan-1", ScanAction.STOP) - def test_run_action_failure_returns_500_with_safe_true(self): + def test_run_action_failure_returns_500_with_suppress_exceptions(self): self.mock_client.post.side_effect = httpx.HTTPStatusError( "failed", request=MagicMock(), response=MagicMock(status_code=500) ) - status = self.api.run_action("scan-1", ScanAction.STOP, safe=True) + api = ScansAPI(self.mock_client, suppress_exceptions=True) + status = api._run_action("scan-1", ScanAction.STOP) self.assertEqual(status, 500) def test_start_scan(self): - self.api.run_action = MagicMock(return_value=200) - result = self.api.start("scan-abc") - self.api.run_action.assert_called_once_with( - "scan-abc", ScanAction.START, False - ) + api = ScansAPI(self.mock_client) + api._run_action = MagicMock(return_value=200) + result = api.start("scan-abc") + api._run_action.assert_called_once_with("scan-abc", ScanAction.START) self.assertEqual(result, 200) def test_start_scan_failure_raises_httpx_error(self): - self.api.run_action = MagicMock( + api = ScansAPI(self.mock_client) + api._run_action = MagicMock( side_effect=httpx.HTTPStatusError( "Scan start failed", request=MagicMock(), @@ -402,23 +444,24 @@ def test_start_scan_failure_raises_httpx_error(self): ) with self.assertRaises(httpx.HTTPStatusError): - self.api.start("scan-abc") + api.start("scan-abc") - def test_start_scan_failure_returns_500_with_safe_true(self): - self.api.run_action = MagicMock(return_value=500) - result = self.api.start("scan-abc", safe=True) + def test_start_scan_failure_returns_500_with_suppress_exceptions(self): + api = ScansAPI(self.mock_client, suppress_exceptions=True) + api._run_action = MagicMock(return_value=500) + result = api.start("scan-abc") self.assertEqual(result, 500) def test_stop_scan(self): - self.api.run_action = MagicMock(return_value=200) - result = self.api.stop("scan-abc") - self.api.run_action.assert_called_once_with( - "scan-abc", ScanAction.STOP, False - ) + api = ScansAPI(self.mock_client) + api._run_action = MagicMock(return_value=200) + result = api.stop("scan-abc") + api._run_action.assert_called_once_with("scan-abc", ScanAction.STOP) self.assertEqual(result, 200) def test_stop_scan_failure_raises_httpx_error(self): - self.api.run_action = MagicMock( + api = ScansAPI(self.mock_client) + api._run_action = MagicMock( side_effect=httpx.HTTPStatusError( "Scan stop failed", request=MagicMock(), @@ -427,9 +470,10 @@ def test_stop_scan_failure_raises_httpx_error(self): ) with self.assertRaises(httpx.HTTPStatusError): - self.api.stop("scan-abc") + api.stop("scan-abc") - def test_stop_scan_failure_returns_500_with_safe_true(self): - self.api.run_action = MagicMock(return_value=500) - result = self.api.stop("scan-abc", safe=True) + def test_stop_scan_failure_returns_500_with_suppress_exceptions(self): + api = ScansAPI(self.mock_client, suppress_exceptions=True) + api._run_action = MagicMock(return_value=500) + result = api.stop("scan-abc") self.assertEqual(result, 500) diff --git a/tests/protocols/http/openvasd/test_vts.py b/tests/protocols/http/openvasd/test_vts.py index cb3dbf31..bc14b1f6 100644 --- a/tests/protocols/http/openvasd/test_vts.py +++ b/tests/protocols/http/openvasd/test_vts.py @@ -20,13 +20,13 @@ def _mock_response(status_code=200): class TestVtsAPI(unittest.TestCase): def setUp(self): self.mock_client = MagicMock(spec=httpx.Client) - self.api = VtsAPI(self.mock_client) def test_get_all_returns_response(self): mock_response = _mock_response(200) self.mock_client.get.return_value = mock_response - response = self.api.get_all() + api = VtsAPI(self.mock_client) + response = api.get_all() self.mock_client.get.assert_called_once_with("/vts") mock_response.raise_for_status.assert_called_once() self.assertEqual(response, mock_response) @@ -35,10 +35,13 @@ def test_get_all_raises_httpx_error(self): self.mock_client.get.side_effect = httpx.HTTPStatusError( "Error", request=MagicMock(), response=MagicMock(status_code=500) ) + api = VtsAPI(self.mock_client) with self.assertRaises(httpx.HTTPStatusError): - self.api.get_all() + api.get_all() - def test_get_all_returns_response_on_httpx_error_with_safe_true(self): + def test_get_all_returns_response_on_httpx_error_with_suppress_exceptions( + self, + ): mock_response = _mock_response() mock_http_error = httpx.HTTPStatusError( "Server failed", @@ -48,7 +51,8 @@ def test_get_all_returns_response_on_httpx_error_with_safe_true(self): mock_response.raise_for_status.side_effect = mock_http_error self.mock_client.get.return_value = mock_response - result = self.api.get_all(safe=True) + api = VtsAPI(self.mock_client, suppress_exceptions=True) + result = api.get_all() self.mock_client.get.assert_called_once_with("/vts") self.assertEqual(result, mock_http_error.response) @@ -58,7 +62,8 @@ def test_get_by_oid_returns_response(self): self.mock_client.get.return_value = mock_response oid = "1.3.6.1.4.1.25623.1.0.123456" - response = self.api.get(oid) + api = VtsAPI(self.mock_client) + response = api.get(oid) self.mock_client.get.assert_called_once_with(f"/vts/{oid}") mock_response.raise_for_status.assert_called_once() self.assertEqual(response, mock_response) @@ -70,10 +75,11 @@ def test_get_by_oid_raises_httpx_error(self): response=MagicMock(status_code=404), ) + api = VtsAPI(self.mock_client) with self.assertRaises(httpx.HTTPStatusError): - self.api.get("nonexistent-oid") + api.get("nonexistent-oid") - def test_get_by_oid_response_on_httpx_error_with_safe_true(self): + def test_get_by_oid_response_on_httpx_error_with_suppress_exceptions(self): mock_response = _mock_response() mock_http_error = httpx.HTTPStatusError( "Not Found", @@ -83,6 +89,7 @@ def test_get_by_oid_response_on_httpx_error_with_safe_true(self): mock_response.raise_for_status.side_effect = mock_http_error self.mock_client.get.return_value = mock_response - response = self.api.get("nonexistent-oid", safe=True) + api = VtsAPI(self.mock_client, suppress_exceptions=True) + response = api.get("nonexistent-oid") self.assertEqual(response, mock_http_error.response) From 6d1197a9fb5a8a0b42951972a5ff564af4956d5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Tue, 8 Jul 2025 11:44:20 +0200 Subject: [PATCH 31/34] Improve docs for openvasd http v1 API Use markdown for all sources. --- docs/api/health.rst | 7 ------- docs/api/http.md | 16 ++++++++++++++++ docs/api/http.rst | 11 ----------- docs/api/metadata.rst | 7 ------- docs/api/notus.rst | 7 ------- docs/api/openvasdv1.md | 18 ++++++++++++++++++ docs/api/openvasdv1.rst | 21 --------------------- docs/api/openvasdv1/health.md | 6 ++++++ docs/api/openvasdv1/metadata.md | 6 ++++++ docs/api/openvasdv1/notus.md | 6 ++++++ docs/api/openvasdv1/scans.md | 6 ++++++ docs/api/openvasdv1/vts.md | 6 ++++++ docs/api/protocols.md | 1 + docs/api/scans.rst | 7 ------- docs/api/vts.rst | 7 ------- gvm/protocols/http/__init__.py | 5 +---- gvm/protocols/http/openvasd/__init__.py | 11 ++++++----- 17 files changed, 72 insertions(+), 76 deletions(-) delete mode 100644 docs/api/health.rst create mode 100644 docs/api/http.md delete mode 100644 docs/api/http.rst delete mode 100644 docs/api/metadata.rst delete mode 100644 docs/api/notus.rst create mode 100644 docs/api/openvasdv1.md delete mode 100644 docs/api/openvasdv1.rst create mode 100644 docs/api/openvasdv1/health.md create mode 100644 docs/api/openvasdv1/metadata.md create mode 100644 docs/api/openvasdv1/notus.md create mode 100644 docs/api/openvasdv1/scans.md create mode 100644 docs/api/openvasdv1/vts.md delete mode 100644 docs/api/scans.rst delete mode 100644 docs/api/vts.rst diff --git a/docs/api/health.rst b/docs/api/health.rst deleted file mode 100644 index 4d17c629..00000000 --- a/docs/api/health.rst +++ /dev/null @@ -1,7 +0,0 @@ -Health API -========== - -.. automodule:: gvm.protocols.http.openvasd.health - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/api/http.md b/docs/api/http.md new file mode 100644 index 00000000..c6b2a3a5 --- /dev/null +++ b/docs/api/http.md @@ -0,0 +1,16 @@ +(http)= + +# HTTP APIs + +```{eval-rst} +.. automodule:: gvm.protocols.http +``` + +The following modules are provided: + +```{eval-rst} +.. toctree:: + :maxdepth: 1 + + openvasdv1 +``` diff --git a/docs/api/http.rst b/docs/api/http.rst deleted file mode 100644 index 888d9c45..00000000 --- a/docs/api/http.rst +++ /dev/null @@ -1,11 +0,0 @@ -.. _http: - -HTTP APIs ---------- - -.. automodule:: gvm.protocols.http - -.. toctree:: - :maxdepth: 1 - - openvasdv1 \ No newline at end of file diff --git a/docs/api/metadata.rst b/docs/api/metadata.rst deleted file mode 100644 index 2a6c7c9b..00000000 --- a/docs/api/metadata.rst +++ /dev/null @@ -1,7 +0,0 @@ -Metadata API -============ - -.. automodule:: gvm.protocols.http.openvasd.metadata - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file diff --git a/docs/api/notus.rst b/docs/api/notus.rst deleted file mode 100644 index a09b632c..00000000 --- a/docs/api/notus.rst +++ /dev/null @@ -1,7 +0,0 @@ -Notus API -========= - -.. automodule:: gvm.protocols.http.openvasd.notus - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file diff --git a/docs/api/openvasdv1.md b/docs/api/openvasdv1.md new file mode 100644 index 00000000..948aa0e2 --- /dev/null +++ b/docs/api/openvasdv1.md @@ -0,0 +1,18 @@ +(openvasdv1)= + +# openvasd v1 + +```{eval-rst} +.. automodule:: gvm.protocols.http.openvasd + :members: +``` + +```{toctree} +:hidden: + +openvasdv1/health +openvasdv1/metadata +openvasdv1/notus +openvasdv1/scans +openvasdv1/vts +``` diff --git a/docs/api/openvasdv1.rst b/docs/api/openvasdv1.rst deleted file mode 100644 index ccaeb4aa..00000000 --- a/docs/api/openvasdv1.rst +++ /dev/null @@ -1,21 +0,0 @@ -.. _openvasdv1: - -openvasd v1 -^^^^^^^^^^^ - -.. automodule:: gvm.protocols.http.openvasd.openvasdv1 - :members: - :undoc-members: - :show-inheritance: - -Submodules ----------- - -.. toctree:: - :maxdepth: 1 - - health - metadata - notus - scans - vts diff --git a/docs/api/openvasdv1/health.md b/docs/api/openvasdv1/health.md new file mode 100644 index 00000000..26ba8582 --- /dev/null +++ b/docs/api/openvasdv1/health.md @@ -0,0 +1,6 @@ +# Health API + +```{eval-rst} +.. automodule:: gvm.protocols.http.openvasd._health + :members: +``` diff --git a/docs/api/openvasdv1/metadata.md b/docs/api/openvasdv1/metadata.md new file mode 100644 index 00000000..5a70a016 --- /dev/null +++ b/docs/api/openvasdv1/metadata.md @@ -0,0 +1,6 @@ +# Metadata API + +```{eval-rst} +.. automodule:: gvm.protocols.http.openvasd._metadata + :members: +``` diff --git a/docs/api/openvasdv1/notus.md b/docs/api/openvasdv1/notus.md new file mode 100644 index 00000000..4ba959cd --- /dev/null +++ b/docs/api/openvasdv1/notus.md @@ -0,0 +1,6 @@ +# Notus API + +```{eval-rst} +.. automodule:: gvm.protocols.http.openvasd._notus + :members: +``` diff --git a/docs/api/openvasdv1/scans.md b/docs/api/openvasdv1/scans.md new file mode 100644 index 00000000..474bc3f6 --- /dev/null +++ b/docs/api/openvasdv1/scans.md @@ -0,0 +1,6 @@ +# Scans API + +```{eval-rst} +.. automodule:: gvm.protocols.http.openvasd._scans + :members: +``` diff --git a/docs/api/openvasdv1/vts.md b/docs/api/openvasdv1/vts.md new file mode 100644 index 00000000..e7479297 --- /dev/null +++ b/docs/api/openvasdv1/vts.md @@ -0,0 +1,6 @@ +# VTs API + +```{eval-rst} +.. automodule:: gvm.protocols.http.openvasd._vts + :members: +``` diff --git a/docs/api/protocols.md b/docs/api/protocols.md index 587379e2..9662d09a 100644 --- a/docs/api/protocols.md +++ b/docs/api/protocols.md @@ -11,6 +11,7 @@ gmp ospv1 +http ``` ## Dynamic diff --git a/docs/api/scans.rst b/docs/api/scans.rst deleted file mode 100644 index 7c28d3fd..00000000 --- a/docs/api/scans.rst +++ /dev/null @@ -1,7 +0,0 @@ -Scans API -========= - -.. automodule:: gvm.protocols.http.openvasd.scans - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file diff --git a/docs/api/vts.rst b/docs/api/vts.rst deleted file mode 100644 index 3beea008..00000000 --- a/docs/api/vts.rst +++ /dev/null @@ -1,7 +0,0 @@ -Vts API -======= - -.. automodule:: gvm.protocols.http.openvasd.vts - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file diff --git a/gvm/protocols/http/__init__.py b/gvm/protocols/http/__init__.py index 87e62d3f..7facd4c4 100644 --- a/gvm/protocols/http/__init__.py +++ b/gvm/protocols/http/__init__.py @@ -5,8 +5,5 @@ """ Package for supported Greenbone HTTP APIs. -Currently only `openvasd version 1`_ is supported. - -.. _openvasd version 1: - https://greenbone.github.io/scanner-api/#/ +Currently only `openvasd version 1 `_ is supported. """ diff --git a/gvm/protocols/http/openvasd/__init__.py b/gvm/protocols/http/openvasd/__init__.py index 539d6ece..ac92d345 100644 --- a/gvm/protocols/http/openvasd/__init__.py +++ b/gvm/protocols/http/openvasd/__init__.py @@ -3,13 +3,14 @@ # SPDX-License-Identifier: GPL-3.0-or-later """ -Package for sending requests to openvasd and handling HTTP API responses. - -Modules: -- :class:`OpenvasdHttpApiV1` – Main class for communicating with OpenVASD API v1. +High-level API interface for interacting with openvasd HTTP services via +logical modules (health, metadata, scans, etc.). Usage: - from gvm.protocols.http.openvasd import OpenvasdHttpApiV1 + +.. code-block:: python + + from gvm.protocols.http.openvasd import OpenvasdHttpAPIv1 """ from ._openvasd1 import OpenvasdHttpAPIv1 From 0694afb2d1112d04346389ec050b583098d21b61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Tue, 8 Jul 2025 11:54:06 +0200 Subject: [PATCH 32/34] Improve returned objects of Metadata API methods Return dataclasses instead of untyped dicts. --- gvm/protocols/http/openvasd/_metadata.py | 85 ++++++++++++------- .../protocols/http/openvasd/test_metadata.py | 32 +++++-- 2 files changed, 79 insertions(+), 38 deletions(-) diff --git a/gvm/protocols/http/openvasd/_metadata.py b/gvm/protocols/http/openvasd/_metadata.py index ad7ea130..d701c375 100644 --- a/gvm/protocols/http/openvasd/_metadata.py +++ b/gvm/protocols/http/openvasd/_metadata.py @@ -6,6 +6,7 @@ API wrapper for retrieving metadata from the openvasd HTTP API using HEAD requests. """ +from dataclasses import dataclass from typing import Union import httpx @@ -13,6 +14,36 @@ from ._api import OpenvasdAPI +@dataclass +class Metadata: + """ + Represents metadata returned by the openvasd API. + + Attributes: + api_version: Comma separated list of available API versions + feed_version: Version of the feed. + authentication: Supported authentication methods + """ + + api_version: str + feed_version: str + authentication: str + + +@dataclass +class MetadataError: + """ + Represents an error response from the metadata API. + + Attributes: + error: Error message. + status_code: HTTP status code of the error response. + """ + + error: str + status_code: int + + class MetadataAPI(OpenvasdAPI): """ Provides access to metadata endpoints exposed by the openvasd server @@ -27,20 +58,12 @@ class MetadataAPI(OpenvasdAPI): is handled gracefully. """ - def get(self) -> dict[str, Union[str, int]]: + def get(self) -> Union[Metadata, MetadataError]: """ Perform a HEAD request to `/` to retrieve top-level API metadata. Returns: - A dictionary containing: - - - "api-version" - - "feed-version" - - "authentication" - - Or if exceptions are suppressed and error occurs: - - - {"error": str, "status_code": int} + A Metadata instance or MetadataError if exceptions are suppressed and an error occurs. Raises: httpx.HTTPStatusError: For non-401 HTTP errors if exceptions are not suppressed. @@ -50,30 +73,24 @@ def get(self) -> dict[str, Union[str, int]]: try: response = self._client.head("/") response.raise_for_status() - return { - "api-version": response.headers.get("api-version"), - "feed-version": response.headers.get("feed-version"), - "authentication": response.headers.get("authentication"), - } + return Metadata( + api_version=response.headers.get("api-version"), + feed_version=response.headers.get("feed-version"), + authentication=response.headers.get("authentication"), + ) except httpx.HTTPStatusError as e: if self._suppress_exceptions: - return {"error": str(e), "status_code": e.response.status_code} + return MetadataError( + error=str(e), status_code=e.response.status_code + ) raise - def get_scans(self) -> dict[str, Union[str, int]]: + def get_scans(self) -> Union[Metadata, MetadataError]: """ - Perform a HEAD request to `/scans` to retrieve scan endpoint metadata. + Perform a HEAD request to `/scans` to retrieve scan endpoint metadata. Returns: - A dictionary containing: - - - "api-version" - - "feed-version" - - "authentication" - - Or if safe=True and error occurs: - - - {"error": str, "status_code": int} + A Metadata instance or MetadataError if exceptions are suppressed and an error occurs. Raises: httpx.HTTPStatusError: For non-401 HTTP errors if exceptions are not suppressed. @@ -83,12 +100,14 @@ def get_scans(self) -> dict[str, Union[str, int]]: try: response = self._client.head("/scans") response.raise_for_status() - return { - "api-version": response.headers.get("api-version"), - "feed-version": response.headers.get("feed-version"), - "authentication": response.headers.get("authentication"), - } + return Metadata( + api_version=response.headers.get("api-version"), + feed_version=response.headers.get("feed-version"), + authentication=response.headers.get("authentication"), + ) except httpx.HTTPStatusError as e: if self._suppress_exceptions: - return {"error": str(e), "status_code": e.response.status_code} + return MetadataError( + error=str(e), status_code=e.response.status_code + ) raise diff --git a/tests/protocols/http/openvasd/test_metadata.py b/tests/protocols/http/openvasd/test_metadata.py index 0ffe315c..e6a4e4f5 100644 --- a/tests/protocols/http/openvasd/test_metadata.py +++ b/tests/protocols/http/openvasd/test_metadata.py @@ -7,7 +7,11 @@ import httpx -from gvm.protocols.http.openvasd._metadata import MetadataAPI +from gvm.protocols.http.openvasd._metadata import ( + Metadata, + MetadataAPI, + MetadataError, +) def _mock_head_response(status_code=200, headers=None): @@ -35,7 +39,14 @@ def test_get_successful(self): result = api.get() self.mock_client.head.assert_called_once_with("/") mock_response.raise_for_status.assert_called_once() - self.assertEqual(result, headers) + self.assertEqual( + result, + Metadata( + api_version="1.0", + feed_version="2025.01", + authentication="API-KEY", + ), + ) def test_get_unauthorized_with_suppress_exceptions(self): mock_response = MagicMock(spec=httpx.Response) @@ -47,7 +58,9 @@ def test_get_unauthorized_with_suppress_exceptions(self): api = MetadataAPI(self.mock_client, suppress_exceptions=True) result = api.get() - self.assertEqual(result, {"error": "Unauthorized", "status_code": 401}) + self.assertEqual( + result, MetadataError(error="Unauthorized", status_code=401) + ) def test_get_failure_raises_httpx_error(self): mock_response = _mock_head_response(500, None) @@ -77,7 +90,14 @@ def test_get_scans_successful(self): result = api.get_scans() self.mock_client.head.assert_called_once_with("/scans") mock_response.raise_for_status.assert_called_once() - self.assertEqual(result, headers) + self.assertEqual( + result, + Metadata( + api_version="1.0", + feed_version="2025.01", + authentication="API-KEY", + ), + ) def test_get_scans_unauthorized_with_suppress_exceptions(self): mock_response = MagicMock(spec=httpx.Response) @@ -89,7 +109,9 @@ def test_get_scans_unauthorized_with_suppress_exceptions(self): api = MetadataAPI(self.mock_client, suppress_exceptions=True) result = api.get_scans() - self.assertEqual(result, {"error": "Unauthorized", "status_code": 401}) + self.assertEqual( + result, MetadataError(error="Unauthorized", status_code=401) + ) def test_get_scans_failure_raises_httpx_error(self): mock_response = _mock_head_response(500, None) From e493212bf45c4ca8249a0dde26d3920ddb230ae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Tue, 8 Jul 2025 12:05:21 +0200 Subject: [PATCH 33/34] docs: Add sphinx autobuild for auto rebuilding the docs in a local server Add this tooling for easier docs development. --- docs/Makefile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/Makefile b/docs/Makefile index 09cc0a4a..987aea33 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -11,7 +11,10 @@ BUILDDIR = build help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -.PHONY: help Makefile +.PHONY: help Makefile livehtml + +livehtml: + sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). From bd6468a863a1d121cb8d75a6ac6b638d6835606c Mon Sep 17 00:00:00 2001 From: ozgen Date: Thu, 10 Jul 2025 11:34:32 +0200 Subject: [PATCH 34/34] Add dataclass-based scan creation and get_preferences endpoint --- gvm/protocols/http/openvasd/_scans.py | 253 +++++++++++++++++++- tests/protocols/http/openvasd/test_scans.py | 193 +++++++++++++-- 2 files changed, 417 insertions(+), 29 deletions(-) diff --git a/gvm/protocols/http/openvasd/_scans.py b/gvm/protocols/http/openvasd/_scans.py index 69129592..ba869063 100644 --- a/gvm/protocols/http/openvasd/_scans.py +++ b/gvm/protocols/http/openvasd/_scans.py @@ -7,6 +7,7 @@ """ import urllib.parse +from dataclasses import asdict, dataclass, field from enum import Enum from typing import Any, Optional, Union from uuid import UUID @@ -20,6 +21,211 @@ ID = Union[str, UUID] +@dataclass +class PortRange: + """ + Represents a range of ports. + + Attributes: + start: The starting port number. + end: The ending port number. + """ + + start: int + end: int + + +@dataclass +class Port: + """ + Represents a port configuration for scanning. + + Attributes: + protocol: The protocol to use ("tcp" or "udp"). + range: A list of port ranges to scan. + """ + + protocol: str # e.g., "tcp", "udp" + range: list[PortRange] + + +@dataclass +class CredentialUP: + """ + Represents username/password credentials for a service. + + Attributes: + username: The login username. + password: The login password. + privilege_username: Optional privilege escalation username. + privilege_password: Optional privilege escalation password. + """ + + username: str + password: str + privilege_username: Optional[str] = None + privilege_password: Optional[str] = None + + +@dataclass +class CredentialKRB5: + """ + Represents Kerberos credentials. + + Attributes: + username: Kerberos username. + password: Kerberos password. + realm: Kerberos realm. + kdc: Key Distribution Center hostname. + """ + + username: str + password: str + realm: str + kdc: str + + +@dataclass +class CredentialUSK: + """ + Represents credentials using a user/SSH key combination. + + Attributes: + username: SSH username. + password: Password for SSH key (if encrypted). + private: Private key content or reference. + privilege_username: Optional privilege escalation username. + privilege_password: Optional privilege escalation password. + """ + + username: str + password: str + private: str + privilege_username: Optional[str] = None + privilege_password: Optional[str] = None + + +@dataclass +class CredentialSNMP: + """ + Represents SNMP credentials. + + Attributes: + username: SNMP username. + password: SNMP authentication password. + community: SNMP community string. + auth_algorithm: Authentication algorithm (e.g., "md5"). + privacy_password: Privacy password for SNMPv3. + privacy_algorithm: Privacy algorithm (e.g., "aes"). + """ + + username: str + password: str + community: str + auth_algorithm: str + privacy_password: str + privacy_algorithm: str + + +@dataclass +class Credential: + """ + Represents a full credential configuration for a specific service. + + Attributes: + service: Name of the service (e.g., "ssh", "snmp"). + port: Port number associated with the service. + up: Optional username/password credentials. + krb5: Optional Kerberos credentials. + usk: Optional user/SSH key credentials. + snmp: Optional SNMP credentials. + """ + + service: str + port: int + up: Optional[CredentialUP] = None + krb5: Optional[CredentialKRB5] = None + usk: Optional[CredentialUSK] = None + snmp: Optional[CredentialSNMP] = None + + +@dataclass +class Target: + """ + Represents the scan target configuration. + + Attributes: + hosts: List of target IPs or hostnames. + excluded_hosts: List of IPs or hostnames to exclude. + ports: List of port configurations. + credentials: List of credentials to use during scanning. + alive_test_ports: Port ranges used to test if hosts are alive. + alive_test_methods: Methods used to check if hosts are alive (e.g., "icmp"). + reverse_lookup_unify: Whether to unify reverse lookup results. + reverse_lookup_only: Whether to rely solely on reverse DNS lookups. + """ + + hosts: list[str] + excluded_hosts: list[str] = field(default_factory=list) + ports: list[Port] = field(default_factory=list) + credentials: list[Credential] = field(default_factory=list) + alive_test_ports: list[Port] = field(default_factory=list) + alive_test_methods: list[str] = field(default_factory=list) + reverse_lookup_unify: bool = False + reverse_lookup_only: bool = False + + +@dataclass +class VTParameter: + """ + Represents a parameter for a specific vulnerability test. + + Attributes: + id: Identifier of the VT parameter. + value: Value to assign to the parameter. + """ + + id: int + value: str + + +@dataclass +class VTSelection: + """ + Represents a selected vulnerability test (VT) and its parameters. + + Attributes: + oid: The OID (Object Identifier) of the VT. + parameters: A list of parameters to customize VT behavior. + """ + + oid: str + parameters: list[VTParameter] = field(default_factory=list) + + +@dataclass +class ScanPreference: + """ + Represents a scan-level preference or configuration option. + + Attributes: + id: Preference ID or name (e.g., "max_checks", "scan_speed"). + value: The value assigned to the preference. + """ + + id: str + value: str + + +def _to_dict(obj: Any) -> Any: + """Recursively convert dataclass instances to dictionaries.""" + if isinstance(obj, list): + return [_to_dict(item) for item in obj] + elif hasattr(obj, "__dataclass_fields__"): + return {k: _to_dict(v) for k, v in asdict(obj).items() if v is not None} + return obj + + class ScanAction(str, Enum): """ Enumeration of valid scan actions supported by the openvasd API. @@ -47,21 +253,21 @@ class ScansAPI(OpenvasdAPI): def create( self, - target: dict[str, Any], - vt_selection: list[dict[str, Any]], + target: Target, + vt_selection: list[VTSelection], *, - scanner_params: Optional[dict[str, Any]] = None, + scan_preferences: Optional[list[ScanPreference]] = None, ) -> httpx.Response: """ - Create a new scan with the specified target and VT selection. + Create a new scan with the specified target configuration and VT selection. Args: - target: Dictionary describing the scan target (e.g., host and port). - vt_selection: List of dictionaries specifying which VTs to run. - scanner_params: Optional dictionary of scan preferences. + target: A `Target` dataclass instance describing the scan target(s), including hosts, ports, credentials, and alive test settings. + vt_selection: A list of `VTSelection` instances specifying which vulnerability tests (VTs) to include in the scan. + scan_preferences: Optional list of `ScanPreference` instances to customize scan behavior (e.g., number of threads, timeout values). Returns: - The full HTTP response of the POST /scans request. + The full HTTP response returned by the POST /scans request. Raises: httpx.HTTPStatusError: If the server responds with an error status @@ -69,9 +275,12 @@ def create( See: POST /scans in the openvasd API documentation. """ - request_json = {"target": target, "vts": vt_selection} - if scanner_params: - request_json["scan_preferences"] = scanner_params + request_json = { + "target": _to_dict(target), + "vts": _to_dict(vt_selection), + } + if scan_preferences: + request_json["scan_preferences"] = _to_dict(scan_preferences) try: response = self._client.post("/scans", json=request_json) @@ -329,3 +538,25 @@ def stop(self, scan_id: ID) -> int: See: POST /scans/{id} with action=stop in the openvasd API documentation. """ return self._run_action(scan_id, ScanAction.STOP) + + def get_preferences(self) -> httpx.Response: + """ + Retrieve all available scan preferences from the scanner. + + Returns: + The full HTTP response of the GET /scans/preferences request. + + Raises: + httpx.HTTPStatusError: If the server responds with an error status + and the exception is not suppressed. + + See: GET /scans/preferences in the openvasd API documentation. + """ + try: + response = self._client.get("/scans/preferences") + response.raise_for_status() + return response + except httpx.HTTPStatusError as e: + if self._suppress_exceptions: + return e.response + raise diff --git a/tests/protocols/http/openvasd/test_scans.py b/tests/protocols/http/openvasd/test_scans.py index de5f7f87..fbef1497 100644 --- a/tests/protocols/http/openvasd/test_scans.py +++ b/tests/protocols/http/openvasd/test_scans.py @@ -8,7 +8,16 @@ import httpx from gvm.errors import InvalidArgumentType -from gvm.protocols.http.openvasd._scans import ScanAction, ScansAPI +from gvm.protocols.http.openvasd._scans import ( + Credential, + CredentialUP, + ScanAction, + ScanPreference, + ScansAPI, + Target, + VTParameter, + VTSelection, +) def _mock_response(status_code=200, json_data=None): @@ -28,15 +37,65 @@ def test_create_scan_success(self): self.mock_client.post.return_value = mock_resp api = ScansAPI(self.mock_client) - api.create( - {"host": "localhost"}, [{"id": "1"}], scanner_params={"threads": 4} + + target = Target( + hosts=["localhost"], + credentials=[ + Credential( + service="ssh", + port=22, + up=CredentialUP(username="user", password="pass"), + ) + ], ) + vt_selection = [ + VTSelection( + oid="1.3.6.1.4.1.25623.1.0.100001", + parameters=[VTParameter(id=1, value="true")], + ) + ] + preferences = [ + ScanPreference(id="auto_enable_dependencies", value="True") + ] + + api.create(target, vt_selection, scan_preferences=preferences) + self.mock_client.post.assert_called_once_with( "/scans", json={ - "target": {"host": "localhost"}, - "vts": [{"id": "1"}], - "scan_preferences": {"threads": 4}, + "target": { + "hosts": ["localhost"], + "excluded_hosts": [], + "ports": [], + "credentials": [ + { + "service": "ssh", + "port": 22, + "up": { + "username": "user", + "password": "pass", + "privilege_username": None, + "privilege_password": None, + }, + "krb5": None, + "usk": None, + "snmp": None, + } + ], + "alive_test_ports": [], + "alive_test_methods": [], + "reverse_lookup_unify": False, + "reverse_lookup_only": False, + }, + "vts": [ + { + "oid": "1.3.6.1.4.1.25623.1.0.100001", + "parameters": [{"id": 1, "value": "true"}], + } + ], + "scan_preferences": [ + {"id": "auto_enable_dependencies", "value": "True"} + ], }, ) @@ -45,11 +104,29 @@ def test_create_scan_without_scan_preferences(self): self.mock_client.post.return_value = mock_resp api = ScansAPI(self.mock_client) - api.create({"host": "localhost"}, [{"id": "1"}]) + + target = Target(hosts=["localhost"]) + vt_selection = [VTSelection(oid="1.3.6.1.4.1.25623.1.0.100001")] + + api.create(target, vt_selection) self.mock_client.post.assert_called_once_with( "/scans", - json={"target": {"host": "localhost"}, "vts": [{"id": "1"}]}, + json={ + "target": { + "hosts": ["localhost"], + "excluded_hosts": [], + "ports": [], + "credentials": [], + "alive_test_ports": [], + "alive_test_methods": [], + "reverse_lookup_unify": False, + "reverse_lookup_only": False, + }, + "vts": [ + {"oid": "1.3.6.1.4.1.25623.1.0.100001", "parameters": []} + ], + }, ) def test_create_scan_failure_raises_httpx_error(self): @@ -62,17 +139,29 @@ def test_create_scan_failure_raises_httpx_error(self): self.mock_client.post.return_value = mock_response api = ScansAPI(self.mock_client) + + target = Target(hosts=["localhost"]) + vt_selection = [VTSelection(oid="test-oid")] + preferences = [ScanPreference(id="test", value="4")] + with self.assertRaises(httpx.HTTPStatusError): - api.create( - {"host": "localhost"}, [{"id": "1"}], scanner_params={"test": 4} - ) + api.create(target, vt_selection, scan_preferences=preferences) self.mock_client.post.assert_called_once_with( "/scans", json={ - "target": {"host": "localhost"}, - "vts": [{"id": "1"}], - "scan_preferences": {"test": 4}, + "target": { + "hosts": ["localhost"], + "excluded_hosts": [], + "ports": [], + "credentials": [], + "alive_test_ports": [], + "alive_test_methods": [], + "reverse_lookup_unify": False, + "reverse_lookup_only": False, + }, + "vts": [{"oid": "test-oid", "parameters": []}], + "scan_preferences": [{"id": "test", "value": "4"}], }, ) @@ -87,16 +176,30 @@ def test_create_scan_failure_httpx_error_with_suppress_exceptions(self): self.mock_client.post.return_value = mock_response api = ScansAPI(self.mock_client, suppress_exceptions=True) + + target = Target(hosts=["localhost"]) + vt_selection = [VTSelection(oid="1")] + preferences = [ScanPreference(id="test", value="4")] + response = api.create( - {"host": "localhost"}, [{"id": "1"}], scanner_params={"test": 4} + target, vt_selection, scan_preferences=preferences ) self.mock_client.post.assert_called_once_with( "/scans", json={ - "target": {"host": "localhost"}, - "vts": [{"id": "1"}], - "scan_preferences": {"test": 4}, + "target": { + "hosts": ["localhost"], + "excluded_hosts": [], + "ports": [], + "credentials": [], + "alive_test_ports": [], + "alive_test_methods": [], + "reverse_lookup_unify": False, + "reverse_lookup_only": False, + }, + "vts": [{"oid": "1", "parameters": []}], + "scan_preferences": [{"id": "test", "value": "4"}], }, ) @@ -477,3 +580,57 @@ def test_stop_scan_failure_returns_500_with_suppress_exceptions(self): api._run_action = MagicMock(return_value=500) result = api.stop("scan-abc") self.assertEqual(result, 500) + + def test_get_preferences_success(self): + mock_resp = _mock_response( + 200, + [ + { + "id": "optimize_test", + "name": "Optimize Test", + "default": True, + "description": "By default, optimize_test is enabled...", + } + ], + ) + self.mock_client.get.return_value = mock_resp + + api = ScansAPI(self.mock_client) + response = api.get_preferences() + + self.mock_client.get.assert_called_once_with("/scans/preferences") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()[0]["id"], "optimize_test") + + def test_get_preferences_failure_raises_httpx_error(self): + mock_resp = _mock_response() + mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "Server error", + request=MagicMock(), + response=MagicMock(status_code=500), + ) + self.mock_client.get.return_value = mock_resp + + api = ScansAPI(self.mock_client) + + with self.assertRaises(httpx.HTTPStatusError): + api.get_preferences() + + self.mock_client.get.assert_called_once_with("/scans/preferences") + + def test_get_preferences_suppressed_exception_returns_response(self): + mock_error_response = MagicMock(status_code=400) + mock_resp = MagicMock() + mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "Bad Request", + request=MagicMock(), + response=mock_error_response, + ) + self.mock_client.get.return_value = mock_resp + + api = ScansAPI(self.mock_client, suppress_exceptions=True) + response = api.get_preferences() + + self.mock_client.get.assert_called_once_with("/scans/preferences") + self.assertEqual(response, mock_error_response) + self.assertEqual(response.status_code, 400)