diff --git a/config.schema.yaml b/config.schema.yaml index 20ceb8480..1f545282f 100644 --- a/config.schema.yaml +++ b/config.schema.yaml @@ -108,6 +108,36 @@ icinga_accounts: secret: false default: "" +wazuh_accounts: + type: "array" + required: true + description: Wazuh account + items: + type: "object" + required: true + properties: + name: + description: "Name of the wazuh account" + type: "string" + secret: false + required: true + username: + type: "string" + description: "Username for wazuh" + secret: false + required: true + password: + type: "string" + description: "Password for wazuh" + required: true + secret: true + wazuh_endpoint: + type: "string" + description: "Wazuh endpoint (URL and port). E.g. https://wazuh.matrix.net:55000/" + required: true + secret: false + default: "" + alertmanager_accounts: type: "array" required: true @@ -137,6 +167,35 @@ alertmanager_accounts: secret: false default: "" +elog_accounts: + type: "array" + required: true + description: ELOG account + items: + type: "object" + required: true + properties: + name: + description: "Name of the ELOG account" + type: "string" + secret: false + required: true + username: + type: "string" + description: "Username for ELOG" + required: true + password: + type: "string" + description: "Password for ELOG" + required: true + secret: true + elog_endpoint: + type: "string" + description: "ELOG endpoint" + required: true + secret: false + default: "" + sensor_cloud_account: description: Which cloud account sensors requiring an openstack connection will use type: "string" diff --git a/lib/apis/elog_api/__init__.py b/lib/apis/elog_api/__init__.py new file mode 100644 index 000000000..26f8b3477 --- /dev/null +++ b/lib/apis/elog_api/__init__.py @@ -0,0 +1,9 @@ +import logging + +logger = logging.getLogger(__name__) + +logger.setLevel(logging.DEBUG) +handler = logging.StreamHandler() +formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +handler.setFormatter(formatter) +logger.addHandler(handler) diff --git a/lib/apis/elog_api/elog.py b/lib/apis/elog_api/elog.py new file mode 100644 index 000000000..73e3e4c81 --- /dev/null +++ b/lib/apis/elog_api/elog.py @@ -0,0 +1,35 @@ +import logging +from apis.elog_api.structs.elog_account import ElogAccount + +logger = logging.getLogger(__name__) + + +def add_record_to_elog(elog_account: ElogAccount, subject: str, body: str) -> None: + """ + adds a new record to the ELOG server + + :param subject: the title of the new record + :type subject: str + :param body: the text that goes in the record + :type body: str + """ + logger.info("Adding a new record to ELOG") + logger.info("subject = %s", subject) + logger.info("body = %s", body) + entry_data = { + "cmd": "Submit", + "Category": "Routine", + "Subject": subject, + "Author": elog_account.username, + "Encoding": "plain", + "Text": body, + } + session = elog_account.authenticate() + submit_response = session.post( + elog_account.elog_endpoint, + files={k: (None, v) for k, v in entry_data.items()}, + ) + submit_response.raise_for_status() + logger.debug("HTTP Status: %s", submit_response.status_code) + logger.debug(submit_response.text) + logger.info("New record added successfully to ELOG") diff --git a/lib/apis/elog_api/structs/__init__.py b/lib/apis/elog_api/structs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lib/apis/elog_api/structs/elog_account.py b/lib/apis/elog_api/structs/elog_account.py new file mode 100644 index 000000000..dfbb2e245 --- /dev/null +++ b/lib/apis/elog_api/structs/elog_account.py @@ -0,0 +1,82 @@ +from dataclasses import dataclass, fields +from typing import Dict +from requests import Session + + +@dataclass +class ElogAccount: + """ + Elog account Parameters. + :param username: Elog API username + :param password: Elog API password + :param elog_endpoint: Elog API endpoint url + """ + + username: str + password: str + elog_endpoint: str + + @staticmethod + def from_dict(dictionary: Dict): + """ + Returns instance of this dataclass from a dictionary (for loading from config) + """ + field_set = {field.name for field in fields(ElogAccount) if field.init} + filtered_arg_dict = { + key: value for key, value in dictionary.items() if key in field_set + } + return ElogAccount(**filtered_arg_dict) + + @staticmethod + def from_pack_config(pack_config: dict, elog_account_name: str): + """ + Returns instance of this dataclass from StackStorm pack config + :param pack_config: The pack config + :param elog_account_name: The account name to get from the config + :raises ValueError: When the pack config does not have elog_accounts defined + :raises KeyError: When the account does not appear in the given config + :return: (Dictionary) Elog account names and properties + """ + elog_accounts_config = pack_config.get("elog_accounts", None) + + if elog_accounts_config is None: + raise ValueError("Pack config must contain the 'elog_accounts' field") + + try: + key_value = {config["name"]: config for config in elog_accounts_config} + account_data = key_value[elog_account_name] + except KeyError as exc: + raise KeyError( + f"The account {elog_account_name} does not appear in the configuration" + ) from exc + + return ElogAccount.from_dict(account_data) + + def authenticate(self) -> Session: + """ + authenticate against the ELOG server + + This is the equivalent of this curl command to get a cookie + curl -k -c cookies.txt \ + --form 'cmd=Login' \ + --form 'uname=' \ + --form 'upassword=' \ + '' + Using "files" with (None, value) causes requests to send + multipart/form-data, matching curl --form. + + :return: a session object + """ + login_data = { + "cmd": "Login", + "uname": self.username, + "upassword": self.password, + } + session = Session() + session.verify = False + login_response = session.post( + self.elog_endpoint, + files={k: (None, v) for k, v in login_data.items()}, + ) + login_response.raise_for_status() + return session diff --git a/lib/apis/wazuh_api/__init__.py b/lib/apis/wazuh_api/__init__.py index e69de29bb..26f8b3477 100644 --- a/lib/apis/wazuh_api/__init__.py +++ b/lib/apis/wazuh_api/__init__.py @@ -0,0 +1,9 @@ +import logging + +logger = logging.getLogger(__name__) + +logger.setLevel(logging.DEBUG) +handler = logging.StreamHandler() +formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +handler.setFormatter(formatter) +logger.addHandler(handler) diff --git a/lib/apis/wazuh_api/structs/__init__.py b/lib/apis/wazuh_api/structs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lib/apis/wazuh_api/structs/wazuh_account.py b/lib/apis/wazuh_api/structs/wazuh_account.py new file mode 100644 index 000000000..7c493c0b0 --- /dev/null +++ b/lib/apis/wazuh_api/structs/wazuh_account.py @@ -0,0 +1,73 @@ +from dataclasses import dataclass, fields +from typing import Dict +import requests + + +@dataclass +class WazuhAccount: + """ + Wazuh account Parameters. + :param username: Wazuh API username + :param password: Wazuh API password + :param wazuh_endpoint: Wazuh API endpoint url + """ + + username: str + password: str + wazuh_endpoint: str + + @staticmethod + def from_dict(dictionary: Dict): + """ + Returns instance of this dataclass from a dictionary (for loading from config) + """ + field_set = {field.name for field in fields(WazuhAccount) if field.init} + filtered_arg_dict = { + key: value for key, value in dictionary.items() if key in field_set + } + return WazuhAccount(**filtered_arg_dict) + + @staticmethod + def from_pack_config(pack_config: dict, wazuh_account_name: str): + """ + Returns instance of this dataclass from StackStorm pack config + :param pack_config: The pack config + :param wazuh_account_name: The account name to get from the config + :raises ValueError: When the pack config does not have wazuh_accounts defined + :raises KeyError: When the account does not appear in the given config + :return: (Dictionary) Wazuh account names and properties + """ + wazuh_accounts_config = pack_config.get("wazuh_accounts", None) + + if wazuh_accounts_config is None: + raise ValueError("Pack config must contain the 'wazuh_accounts' field") + + try: + key_value = {config["name"]: config for config in wazuh_accounts_config} + account_data = key_value[wazuh_account_name] + except KeyError as exc: + raise KeyError( + f"The account {wazuh_account_name} does not appear in the configuration" + ) from exc + + return WazuhAccount.from_dict(account_data) + + def get_wazuh_token(self) -> str: + """ + Authenticates with the Wazuh API and returns a JWT token. + returns: API JWT token + """ + url = f"{self.wazuh_endpoint}/security/user/authenticate" + try: + # Wazuh uses Basic Auth to fetch the initial token + response = requests.get( + url, auth=(self.username, self.password), verify=False, timeout=60 + ) + response.raise_for_status() + except requests.exceptions.RequestException as e: + raise RuntimeError("Unable to retrieve a valid token: %s") from e + try: + token = response.json()["data"]["token"] + except (KeyError, TypeError, requests.exceptions.JSONDecodeError) as e: + raise RuntimeError("Wazuh API response did not contain a token") from e + return token diff --git a/lib/apis/wazuh_api/wazuh_query_agents.py b/lib/apis/wazuh_api/wazuh_query_agents.py new file mode 100644 index 000000000..fe474b68b --- /dev/null +++ b/lib/apis/wazuh_api/wazuh_query_agents.py @@ -0,0 +1,165 @@ +from typing import Optional, List, Dict +import logging +import requests +from apis.wazuh_api.structs.wazuh_account import WazuhAccount + +logger = logging.getLogger(__name__) + + +def _query_agents( + wazuh_token: str, + endpoint: str, + values: Optional[List] = None, + query: Optional[str] = None, +) -> List: + """ + Queries all agents in Wazuh to get a certain list of parameters + for a specific query or filter + + :param wazuh_token: wazuh token to make API call with + :param endpoint: Wazuh endpoint to query + :param values: the list of variables to retrieve + :type values: list + :param query: the specific query against the Wazuh server + :type query: str + :return: the information in Wazuh for all agents + :rtype: list + """ + logger.info("Querying all agents for values %s and query %s", values, query) + url = f"{endpoint}/agents" + headers = { + "Authorization": f"Bearer {wazuh_token}", + "Content-Type": "application/json", + } + # we query Wazuh using pagination + # we do not know how many agents there are + out = [] + params = { + "limit": 500, + "offset": 0, + "select": values, + "q": query, + } + while True: + try: + response = requests.get( + url, headers=headers, params=params, verify=False, timeout=60 + ) + response.raise_for_status() + except requests.exceptions.RequestException as e: + raise RuntimeError("Failed to fetch data for all agents") from e + data = response.json() + items = data.get("data", {}).get("affected_items", []) + # finished pagination loop + if not items: + break + out.extend(items) + # restart pagination loop with new offset + params["offset"] += params["limit"] + logger.info("Data for all agents fetched correctly") + return out + + +def _wazuh_get_labels_for_agent( + wazuh_token: str, + endpoint: str, + agent_id: str, +) -> List[Dict]: + """ + get the entire list of labels associated to a given Agent ID + + :param wazuh_token: wazuh token to make API call with + :type wazuh_token: str + :param endpoint: Wazuh endpoint to query + :type endpoint: str + :param agent_id: the ID of the Wazuh Agent + :type agent_id: str + :return: a list of labels + :rtype: List of Dictionaries + """ + logger.info("Getting labels for agent %s", agent_id) + url = f"{endpoint}/agents/{agent_id}/config/agent/labels" + headers = { + "Authorization": f"Bearer {wazuh_token}", + "Content-Type": "application/json", + } + try: + response = requests.get(url, headers=headers, verify=False, timeout=60) + except requests.exceptions.RequestException as e: + raise RuntimeError(f"Failed to fetch labels for agent {agent_id}") from e + labels = response.json()["data"]["labels"] + logger.info("Found %s labels for agent %s", len(labels), agent_id) + return labels + + +def wazuh_get_server_id_from_label( + wazuh_token: str, + endpoint: str, + agent_id: str, +) -> str: + """ + extract the OpenStack Server ID from the list of labels + associated to the corresponding Agent ID in Wazuh + + :param wazuh_token: wazuh token to make API call with + :type wazuh_token: str + :param endpoint: Wazuh endpoint to query + :type endpoint: str + :param agent_id: the ID of the Wazuh Agent + :type agent_id: str + :return: the Server ID + :rtype: str + """ + logger.info("getting the OpenStack Server ID for Agent ID %s", agent_id) + labels = _wazuh_get_labels_for_agent(wazuh_token, endpoint, agent_id) + for label in labels: + if label["key"] == "openstack.uuid": + server_id = label["value"] + logger.info("found Server ID %s for Agent ID %s", server_id, agent_id) + return server_id + error_msg = f"No Server ID found for Agent ID {agent_id}" + logger.error(error_msg) + raise ValueError(error_msg) + + +def wazuh_list_servers_by_os( + wazuh_account: WazuhAccount, os_name: str, os_version: str +): + """ + query the Wazuh server to get data only for + VMs running a specific version of the OS + + :param os_name: the OS name + :type os_name: str + :param os_version: the OS version + :type os_version: str + :return: the list of VM IDs + :rtype: list + """ + wazuh_token = wazuh_account.get_wazuh_token() + logger.info( + "query Wazuh for Servers running OS name %s and %s version", + os_name, + os_version, + ) + query = f"os.platform={os_name};os.major={os_version};status=active;group!=kolla" + # we ensure we only get information about Servers and not Hypervisors + # by adding conditions + # group!=kolla + # to the query string + data = _query_agents( + wazuh_token, wazuh_account.wazuh_endpoint, values=["name"], query=query + ) + + server_id_list = [] + for agent in data: + agent_id = agent["id"] + try: + server_id = wazuh_get_server_id_from_label( + wazuh_token, wazuh_account.wazuh_endpoint, agent_id + ) + server_id_list.append(server_id) + except ValueError: + logger.error("failed to get Server ID for Agent ID %s", agent_id) + logger.info("returning a list of %s Server IDs", len(server_id_list)) + return server_id_list diff --git a/requirements.txt b/requirements.txt index 3ff2d2762..719415b40 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,9 @@ openstacksdk + +# 5.14.0 is the latest version that we can use for st2 +# but some dependency packages are incompatible, +# pin to previous version so sensors work +keystoneauth1 == 5.13.1 # 5.8+ replaces tenant_id support which train still relies on # for various "side-effects" such as creating "default" security # groups....don't ask... diff --git a/stackstorm_openstack.yaml.example b/stackstorm_openstack.yaml.example deleted file mode 100644 index 8cc327269..000000000 --- a/stackstorm_openstack.yaml.example +++ /dev/null @@ -1,45 +0,0 @@ ---- -jupyter: - prod_token: - training_token: - dev_token: - -jira_accounts: - - name: "default" - username: - api_token: - atlassian_endpoint: - -icinga_accounts: - - name: "default" - username: - password: - icinga_endpoint: - -alertmanager_accounts: - - name: "default" - username: - password: - alertmanager_endpoint: - -smtp_accounts: - - name: "default" - password: - port: - secure: - server: - smtp_auth: - username: -max_attachment_size: 1024 -attachment_datastore_ttl: 1800 - -sensor_cloud_account: dev - -hypervisor_sensor: - uptime_limit: - state_expire_after: - -chatops_sensor: - endpoint: - token: - channel: diff --git a/tests/lib/apis/elog_api/__init__.py b/tests/lib/apis/elog_api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lib/apis/elog_api/test_elog.py b/tests/lib/apis/elog_api/test_elog.py new file mode 100644 index 000000000..519d9d8c7 --- /dev/null +++ b/tests/lib/apis/elog_api/test_elog.py @@ -0,0 +1,44 @@ +from unittest.mock import Mock + +from apis.elog_api.elog import add_record_to_elog + + +def test_add_record_to_elog(): + # Create a fake ELOG account. + elog_account = Mock() + elog_account.username = "test_user" + elog_account.elog_endpoint = "https://elog.example.test" + + # Create a fake authenticated HTTP session. + session = Mock() + elog_account.authenticate.return_value = session + + # Create a fake HTTP response. + response = Mock() + session.post.return_value = response + + # Call the function under test. + add_record_to_elog( + elog_account, + subject="Test subject", + body="Test body", + ) + + # Verify authentication was requested. + elog_account.authenticate.assert_called_once_with() + + # Verify the expected request was sent. + session.post.assert_called_once_with( + "https://elog.example.test", + files={ + "cmd": (None, "Submit"), + "Category": (None, "Routine"), + "Subject": (None, "Test subject"), + "Author": (None, "test_user"), + "Encoding": (None, "plain"), + "Text": (None, "Test body"), + }, + ) + + # Verify HTTP errors would be raised. + response.raise_for_status.assert_called_once_with() diff --git a/tests/lib/apis/elog_api/test_elog_account.py b/tests/lib/apis/elog_api/test_elog_account.py new file mode 100644 index 000000000..11cec454e --- /dev/null +++ b/tests/lib/apis/elog_api/test_elog_account.py @@ -0,0 +1,74 @@ +from typing import List, Dict +import pytest +from apis.elog_api.structs.elog_account import ElogAccount + + +@pytest.fixture(name="mock_elog_accounts") +def mock_elog_accounts_fixture() -> List[Dict]: + """ + Fixture which contains several test jira accounts in a dict + """ + return [ + { + "name": "config1", + "username": "elog", + "password": "pass", + "elog_endpoint": "elog.test.com", + } + ] + + +@pytest.fixture(name="mock_pack_config") +def mock_pack_config_fixture(mock_elog_accounts): + """Fixture sets up a mock pack config to test with""" + return {"elog_accounts": mock_elog_accounts} + + +def test_from_dict(): + """ + Tests that from_dict() static method works properly + this method should build a ElogAccount dataclass from a valid dictionary + """ + mock_valid_kwargs = { + "username": "user1", + "password": "some-pass", + "elog_endpoint": "sever", + } + mock_invalid_kwargs = {"to_ignore1": "val1", "to_ignore2": "val2"} + + res = ElogAccount.from_dict({**mock_valid_kwargs, **mock_invalid_kwargs}) + for key, val in mock_valid_kwargs.items(): + assert val == getattr(res, key) + + +def test_from_pack_config_valid(mock_elog_accounts, mock_pack_config): + """ + Tests that from_pack_config() static method works properly + this method should build a ElogAccount dataclass from a valid + stackstorm pack_config and a elog_account_name + """ + for mock_accounts in mock_elog_accounts: + expected_attrs = dict(mock_accounts) + expected_attrs.pop("name") + + res = ElogAccount.from_pack_config(mock_pack_config, "config1") + for key, val in expected_attrs.items(): + assert val == getattr(res, key) + + +def test_from_pack_config_invalid_name(mock_pack_config): + """ + Tests that from_pack_config() method works properly - when given an invalid jira_account_name + should raise an error if pack config does not contain entry matching jira_account_name + """ + with pytest.raises(KeyError): + ElogAccount.from_pack_config(mock_pack_config, "invalid-config") + + +def test_from_pack_config_invalid_pack(): + """ + Tests that from_pack_config() method works properly - when given an invalid pack_config + should raise an error if pack config could not be found + """ + with pytest.raises(ValueError): + ElogAccount.from_pack_config({}, "config1") diff --git a/tests/lib/apis/wazuh_api/__init__.py b/tests/lib/apis/wazuh_api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lib/apis/wazuh_api/test_wazuh_account.py b/tests/lib/apis/wazuh_api/test_wazuh_account.py new file mode 100644 index 000000000..1ea791f46 --- /dev/null +++ b/tests/lib/apis/wazuh_api/test_wazuh_account.py @@ -0,0 +1,74 @@ +from typing import List, Dict +import pytest +from apis.wazuh_api.structs.wazuh_account import WazuhAccount + + +@pytest.fixture(name="mock_wazuh_accounts") +def mock_wazuh_accounts_fixture() -> List[Dict]: + """ + Fixture which contains a test wazuh account in a dict + """ + return [ + { + "name": "config1", + "username": "wazuh", + "password": "pass", + "wazuh_endpoint": "wazuh.test.com", + } + ] + + +@pytest.fixture(name="mock_pack_config") +def mock_pack_config_fixture(mock_wazuh_accounts): + """Fixture sets up a mock pack config to test with""" + return {"wazuh_accounts": mock_wazuh_accounts} + + +def test_from_dict(): + """ + Tests that from_dict() static method works properly + this method should build a WazuhAccount dataclass from a valid dictionary + """ + mock_valid_kwargs = { + "username": "user1", + "password": "some-pass", + "wazuh_endpoint": "sever", + } + mock_invalid_kwargs = {"to_ignore1": "val1", "to_ignore2": "val2"} + + res = WazuhAccount.from_dict({**mock_valid_kwargs, **mock_invalid_kwargs}) + for key, val in mock_valid_kwargs.items(): + assert val == getattr(res, key) + + +def test_from_pack_config_valid(mock_wazuh_accounts, mock_pack_config): + """ + Tests that from_pack_config() static method works properly + this method should build a WazuhAccount dataclass from a valid + stackstorm pack_config and a wazuh_account_name + """ + for mock_accounts in mock_wazuh_accounts: + expected_attrs = dict(mock_accounts) + expected_attrs.pop("name") + + res = WazuhAccount.from_pack_config(mock_pack_config, "config1") + for key, val in expected_attrs.items(): + assert val == getattr(res, key) + + +def test_from_pack_config_invalid_name(mock_pack_config): + """ + Tests that from_pack_config() method works properly - when given an invalid wazuh_account_name + should raise an error if pack config does not contain entry matching wazuh_account_name + """ + with pytest.raises(KeyError): + WazuhAccount.from_pack_config(mock_pack_config, "invalid-config") + + +def test_from_pack_config_invalid_pack(): + """ + Tests that from_pack_config() method works properly - when given an invalid pack_config + should raise an error if pack config could not be found + """ + with pytest.raises(ValueError): + WazuhAccount.from_pack_config({}, "config1") diff --git a/tests/lib/apis/wazuh_api/test_wazuh_query_agents.py b/tests/lib/apis/wazuh_api/test_wazuh_query_agents.py new file mode 100644 index 000000000..56b8f001c --- /dev/null +++ b/tests/lib/apis/wazuh_api/test_wazuh_query_agents.py @@ -0,0 +1,282 @@ +from unittest.mock import Mock, patch + +import pytest + +from apis.wazuh_api.wazuh_query_agents import ( + _query_agents, + _wazuh_get_labels_for_agent, + wazuh_get_server_id_from_label, + wazuh_list_servers_by_os, +) + + +# pylint: disable=protected-access +# pylint: disable=unused-argument +@patch("apis.wazuh_api.wazuh_query_agents.requests.get") +def test_query_agents_pagination(mock_get): + """ + WHAT IT TESTS: + Verifies `_query_agents` loops through paginated API results until an empty page is returned. + + HOW IT WORKS: + Mocks `requests.get` using `side_effect` with 2 responses: + Page 1 with items and Page 2 empty. + + WHY IT PREVENTS BUGS: + Guarantees offset increments (0 -> 500), pagination headers/tokens are attached, + and the loop terminates correctly on empty responses without causing infinite loops. + """ + first_response = Mock() + first_response.raise_for_status.return_value = None + first_response.json.return_value = { + "data": { + "affected_items": [ + {"id": "001"}, + {"id": "002"}, + ] + } + } + + second_response = Mock() + second_response.raise_for_status.return_value = None + second_response.json.return_value = {"data": {"affected_items": []}} + + responses = [first_response, second_response] + request_params = [] + + def mock_request(*args, **kwargs): + # Store a copy because `_query_agents` mutates the same params dictionary. + request_params.append(kwargs["params"].copy()) + return responses.pop(0) + + mock_get.side_effect = mock_request + + agents = _query_agents( + "jwt-token", + "https://wazuh.example.com", + values=["name"], + query="status=active", + ) + + # Verify aggregated data + assert agents == [{"id": "001"}, {"id": "002"}] + + # Verify exactly 2 HTTP requests were sent + assert mock_get.call_count == 2 + + # Verify the first request + assert request_params[0] == { + "limit": 500, + "offset": 0, + "select": ["name"], + "q": "status=active", + } + + # Verify the second request used the updated offset + assert request_params[1] == { + "limit": 500, + "offset": 500, + "select": ["name"], + "q": "status=active", + } + + +@patch("apis.wazuh_api.wazuh_query_agents.requests.get") +def test_wazuh_get_labels_for_agent(mock_get): + """ + WHAT IT TESTS: + Verifies `_wazuh_get_labels_for_agent()` queries the correct Wazuh endpoint + and returns the labels associated with the requested agent. + + HOW IT WORKS: + Mocks `requests.get` to return a Wazuh response containing multiple labels. + + WHY IT PREVENTS BUGS: + Ensures the correct agent-specific labels endpoint is queried and the labels + are extracted from the expected location in the API response. + """ + response = Mock() + response.json.return_value = { + "data": { + "labels": [ + { + "key": "openstack.uuid", + "value": "001682d2-79a2-41b9-af7f-c553f7d67b0d", + }, + {"key": "environment", "value": "production"}, + ] + } + } + mock_get.return_value = response + + labels = _wazuh_get_labels_for_agent( + "jwt-token", + "https://wazuh.example.com", + "001", + ) + + # Verify labels are returned unchanged + assert labels == [ + {"key": "openstack.uuid", "value": "001682d2-79a2-41b9-af7f-c553f7d67b0d"}, + {"key": "environment", "value": "production"}, + ] + + # Verify the correct Wazuh endpoint was queried + mock_get.assert_called_once_with( + "https://wazuh.example.com/agents/001/config/agent/labels", + headers={ + "Authorization": "Bearer jwt-token", + "Content-Type": "application/json", + }, + verify=False, + timeout=60, + ) + + +@patch("apis.wazuh_api.wazuh_query_agents._wazuh_get_labels_for_agent") +def test_wazuh_get_server_id_from_label(mock_get_labels): + """ + WHAT IT TESTS: + Verifies `wazuh_get_server_id_from_label()` extracts the OpenStack + Server ID from the agent label named `openstack.uuid`. + + HOW IT WORKS: + Mocks `_wazuh_get_labels_for_agent` to return multiple labels, + including the OpenStack UUID label. + + WHY IT PREVENTS BUGS: + Ensures the correct label is selected and its value is returned + as the OpenStack Server ID. + """ + mock_get_labels.return_value = [ + {"key": "environment", "value": "production"}, + { + "key": "openstack.uuid", + "value": "001682d2-79a2-41b9-af7f-c553f7d67b0d", + }, + ] + + server_id = wazuh_get_server_id_from_label( + "jwt-token", + "https://wazuh.example.com", + "001", + ) + + assert server_id == "001682d2-79a2-41b9-af7f-c553f7d67b0d" + + mock_get_labels.assert_called_once_with( + "jwt-token", + "https://wazuh.example.com", + "001", + ) + + +@patch("apis.wazuh_api.wazuh_query_agents._wazuh_get_labels_for_agent") +def test_wazuh_get_server_id_from_label_missing(mock_get_labels): + """ + WHAT IT TESTS: + Verifies `wazuh_get_server_id_from_label()` raises `ValueError` + when the agent does not contain an `openstack.uuid` label. + + HOW IT WORKS: + Mocks `_wazuh_get_labels_for_agent` to return labels without + the required OpenStack UUID label. + + WHY IT PREVENTS BUGS: + Ensures agents without an OpenStack Server ID are explicitly detected + rather than returning an incorrect or undefined value. + """ + mock_get_labels.return_value = [ + {"key": "environment", "value": "production"}, + {"key": "role", "value": "server"}, + ] + + with pytest.raises( + ValueError, + match="No Server ID found for Agent ID 001", + ): + wazuh_get_server_id_from_label( + "jwt-token", + "https://wazuh.example.com", + "001", + ) + + mock_get_labels.assert_called_once_with( + "jwt-token", + "https://wazuh.example.com", + "001", + ) + + +@patch("apis.wazuh_api.wazuh_query_agents.wazuh_get_server_id_from_label") +@patch("apis.wazuh_api.wazuh_query_agents._query_agents") +def test_wazuh_list_servers_by_os(mock_query, mock_get_server_id): + """ + WHAT IT TESTS: + Verifies `wazuh_list_servers_by_os()` formats the Wazuh query string correctly + and retrieves OpenStack Server IDs from the returned Wazuh agents. + + HOW IT WORKS: + Mocks `_query_agents` to return sample Wazuh Agent IDs and mocks + `wazuh_get_server_id_from_label` to return the corresponding Server IDs. + One agent is configured without a Server ID. + + WHY IT PREVENTS BUGS: + Verifies the exact query parameters, ensures Server IDs are retrieved from + agent labels, and confirms agents without a Server ID are skipped. + """ + wazuh_account = Mock() + wazuh_account.wazuh_endpoint = "https://wazuh.example.com" + wazuh_account.get_wazuh_token.return_value = "jwt-token" + + mock_query.return_value = [ + {"id": "001"}, + {"id": "002"}, + {"id": "003"}, + ] + + mock_get_server_id.side_effect = [ + "001682d2-79a2-41b9-af7f-c553f7d67b0d", + "aabbccdd-1234-5678-90ab-cdef12345678", + ValueError("No Server ID found for Agent ID 003"), + ] + + server_ids = wazuh_list_servers_by_os( + wazuh_account=wazuh_account, + os_name="ubuntu", + os_version="22.04", + ) + + expected_ids = [ + "001682d2-79a2-41b9-af7f-c553f7d67b0d", + "aabbccdd-1234-5678-90ab-cdef12345678", + ] + assert server_ids == expected_ids + + wazuh_account.get_wazuh_token.assert_called_once_with() + + expected_query = "os.platform=ubuntu;os.major=22.04;status=active;group!=kolla" + mock_query.assert_called_once_with( + "jwt-token", + "https://wazuh.example.com", + values=["name"], + query=expected_query, + ) + + assert mock_get_server_id.call_count == 3 + + mock_get_server_id.assert_any_call( + "jwt-token", + "https://wazuh.example.com", + "001", + ) + mock_get_server_id.assert_any_call( + "jwt-token", + "https://wazuh.example.com", + "002", + ) + mock_get_server_id.assert_any_call( + "jwt-token", + "https://wazuh.example.com", + "003", + )