From c1aaae81605652e697d7ea0da2451cf5017aaade Mon Sep 17 00:00:00 2001 From: arturo-seijas <102022572+arturo-seijas@users.noreply.github.com> Date: Tue, 3 Dec 2024 11:21:12 +0100 Subject: [PATCH] Secure Wazuh API (#40) --- requirements-charmcraft.txt | 1 + src-docs/charm.py.md | 8 +- src-docs/opensearch_observer.py.md | 2 +- src-docs/state.py.md | 26 +++++-- src-docs/traefik_route_observer.py.md | 2 +- src-docs/wazuh.py.md | 68 +++++++++++++++-- src/charm.py | 24 +++++- src/state.py | 79 +++++++++++++++----- src/traefik_route_observer.py | 1 - src/wazuh.py | 90 ++++++++++++++++++++++- tests/integration/conftest.py | 21 +++++- tests/integration/test_charm.py | 44 +++++------ tests/unit/test_charm.py | 22 +++++- tests/unit/test_state.py | 28 ++++++- tests/unit/test_traefik_route_observer.py | 12 +-- tests/unit/test_wazuh.py | 12 +++ trivy.yaml | 1 + 17 files changed, 354 insertions(+), 87 deletions(-) diff --git a/requirements-charmcraft.txt b/requirements-charmcraft.txt index de4d1798..6f388c0a 100644 --- a/requirements-charmcraft.txt +++ b/requirements-charmcraft.txt @@ -2,4 +2,5 @@ cryptography==43.0.1 jsonschema==4.23.0 ops==2.17.0 pydantic==2.9.2 +requests==2.32.3 rpds-py==0.20.0 diff --git a/src-docs/charm.py.md b/src-docs/charm.py.md index 754d51dd..ce39f202 100644 --- a/src-docs/charm.py.md +++ b/src-docs/charm.py.md @@ -7,7 +7,9 @@ Wazuh server charm. **Global Variables** --------------- +- **WAZUH_API_CREDENTIALS** - **WAZUH_CLUSTER_KEY_SECRET_LABEL** +- **WAZUH_DEFAULT_API_CREDENTIALS** - **WAZUH_PEER_RELATION_NAME** @@ -23,7 +25,7 @@ Charm the service. - `fqdns`: the unit FQDNs. - `state`: the charm state. - + ### function `__init__` @@ -94,12 +96,12 @@ Unit that this execution is responsible for. --- - + ### function `reconcile` ```python -reconcile() → None +reconcile(_: HookEvent) → None ``` Reconcile Wazuh configuration with charm state. diff --git a/src-docs/opensearch_observer.py.md b/src-docs/opensearch_observer.py.md index fbf7d82d..eba8c12e 100644 --- a/src-docs/opensearch_observer.py.md +++ b/src-docs/opensearch_observer.py.md @@ -15,7 +15,7 @@ The Certificates relation observer. ## class `OpenSearchObserver` The Opensearch relation observer. - + ### function `__init__` diff --git a/src-docs/state.py.md b/src-docs/state.py.md index 1ff0061e..847b668a 100644 --- a/src-docs/state.py.md +++ b/src-docs/state.py.md @@ -7,7 +7,9 @@ Wazuh server charm state. **Global Variables** --------------- +- **WAZUH_API_CREDENTIALS** - **WAZUH_CLUSTER_KEY_SECRET_LABEL** +- **WAZUH_DEFAULT_API_CREDENTIALS** --- @@ -56,12 +58,12 @@ Unit that this execution is responsible for. --- - + ### function `reconcile` ```python -reconcile() → None +reconcile(_: HookEvent) → None ``` Reconcile configuration. @@ -134,8 +136,10 @@ The Wazuh server charm state. **Attributes:** - `agent_password`: the agent password. + - `api_credentials`: a map containing the API credentials. - `cluster_key`: the Wazuh key for the cluster nodes. - `indexer_ips`: list of Wazuh indexer IPs. + - `is_default_api_password`: if the default API password is in use. - `filebeat_username`: the filebeat username. - `filebeat_password`: the filebeat password. - `certificate`: the TLS certificate. @@ -144,13 +148,14 @@ The Wazuh server charm state. - `custom_config_ssh_key`: the SSH key for the git repository. - `proxy`: proxy configuration. - + ### function `__init__` ```python __init__( - agent_password: Optional[str], + agent_password: str | None, + api_credentials: dict[str, str], cluster_key: str, indexer_ips: list[str], filebeat_username: str, @@ -158,7 +163,7 @@ __init__( certificate: str, root_ca: str, wazuh_config: WazuhConfig, - custom_config_ssh_key: Optional[str] + custom_config_ssh_key: str | None ) ``` @@ -169,6 +174,7 @@ Initialize a new instance of the CharmState class. **Args:** - `agent_password`: the agent password. + - `api_credentials`: a map ccontaining the API credentials. - `cluster_key`: the Wazuh key for the cluster nodes. - `indexer_ips`: list of Wazuh indexer IPs. - `filebeat_username`: the filebeat username. @@ -179,6 +185,14 @@ Initialize a new instance of the CharmState class. - `custom_config_ssh_key`: the SSH key for the git repository. +--- + +#### property is_default_api_password + +Check if the default API password is in use.. + +Returns: True if the current password is the default + --- #### property model_extra @@ -222,7 +236,7 @@ Get charm proxy configuration from juju charm environment. --- - + ### classmethod `from_charm` diff --git a/src-docs/traefik_route_observer.py.md b/src-docs/traefik_route_observer.py.md index 4bc9466e..a243352d 100644 --- a/src-docs/traefik_route_observer.py.md +++ b/src-docs/traefik_route_observer.py.md @@ -22,7 +22,7 @@ The Traefik route relation observer. - `hostname`: The unit's hostname. - + ### function `__init__` diff --git a/src-docs/wazuh.py.md b/src-docs/wazuh.py.md index 77f2e48b..6ef4d9ad 100644 --- a/src-docs/wazuh.py.md +++ b/src-docs/wazuh.py.md @@ -7,15 +7,15 @@ Wazuh operational logic. **Global Variables** --------------- -- **WAZUH_USER** -- **WAZUH_GROUP** - **KNOWN_HOSTS_PATH** - **RSA_PATH** - **REPOSITORY_PATH** +- **WAZUH_GROUP** +- **WAZUH_USER** --- - + ## function `update_configuration` @@ -50,7 +50,7 @@ Update the workload configuration. --- - + ## function `install_certificates` @@ -77,7 +77,7 @@ Update Wazuh filebeat certificates. --- - + ## function `configure_agent_password` @@ -97,7 +97,7 @@ Configure the agent password. --- - + ## function `configure_git` @@ -123,7 +123,7 @@ Configure git. --- - + ## function `pull_configuration_files` @@ -142,7 +142,7 @@ Pull configuration files from the repository. --- - + ## function `configure_filebeat_user` @@ -165,6 +165,49 @@ Configure the filebeat user. - `password`: the password. +--- + + + +## function `change_api_password` + +```python +change_api_password(username: str, old_password: str, new_password: str) → None +``` + +Change Wazuh's API password for a given user. + + + +**Args:** + + - `username`: the username to change the user for. + - `old_password`: the old API password for the user. + - `new_password`: the new API password for the user. + + + +**Raises:** + + - `WazuhAuthenticationError`: if an authentication error occurs. + - `WazuhInstallationError`: if an error occurs while processing the requests. + + +--- + + + +## function `generate_api_credentials` + +```python +generate_api_credentials() → dict[str, str] +``` + +Generate the credentials for the default API users. + +Returns: a dict containing the new credentials. + + --- ## class `NodeType` @@ -176,6 +219,15 @@ Attrs: WORKER: worker. MASTER: master. +--- + +## class `WazuhAuthenticationError` +Wazuh authentication errors. + + + + + --- ## class `WazuhInstallationError` diff --git a/src/charm.py b/src/charm.py index e064e3a6..797710fa 100755 --- a/src/charm.py +++ b/src/charm.py @@ -17,7 +17,9 @@ import traefik_route_observer import wazuh from state import ( + WAZUH_API_CREDENTIALS, WAZUH_CLUSTER_KEY_SECRET_LABEL, + WAZUH_DEFAULT_API_CREDENTIALS, CharmBaseWithState, InvalidStateError, RecoverableStateError, @@ -133,6 +135,14 @@ def reconcile(self, _: ops.HookEvent) -> None: ) container.add_layer("wazuh", self._pebble_layer, combine=True) container.replan() + + if self.state.is_default_api_password: + credentials = wazuh.generate_api_credentials() + for username, password in credentials.items(): + wazuh.change_api_password( + username, WAZUH_DEFAULT_API_CREDENTIALS[username], password + ) + self.app.add_secret(credentials, label=WAZUH_API_CREDENTIALS) self.unit.status = ops.ActiveStatus() @property @@ -161,7 +171,7 @@ def _pebble_layer(self) -> pebble.LayerDict: }, "filebeat": { "override": "replace", - "summary": "filebear", + "summary": "filebeat", "command": ( "/usr/share/filebeat/bin/filebeat -c /etc/filebeat/filebeat.yml " "--path.home /usr/share/filebeat --path.config /etc/filebeat " @@ -170,6 +180,18 @@ def _pebble_layer(self) -> pebble.LayerDict: "startup": "enabled", }, }, + "checks": { + "wazuh-alive": { + "override": "replace", + "level": "alive", + "tcp": {"port": 55000}, + }, + "filebeat-alive": { + "override": "replace", + "level": "alive", + "exec": {"command": "filebeat test output"}, + }, + }, } @property diff --git a/src/state.py b/src/state.py index bea10ea6..035730b7 100644 --- a/src/state.py +++ b/src/state.py @@ -16,8 +16,13 @@ logger = logging.getLogger(__name__) +WAZUH_API_CREDENTIALS = "wazuh-api-credentials" # Bandit mistakenly thinks this is a password WAZUH_CLUSTER_KEY_SECRET_LABEL = "wazuh-cluster-key" # nosec +WAZUH_DEFAULT_API_CREDENTIALS = { + "wazuh": "wazuh", + "wazuh-wui": "wazuh-wui", +} class CharmBaseWithState(ops.CharmBase, ABC): @@ -45,9 +50,9 @@ class ProxyConfig(BaseModel): # pylint: disable=too-few-public-methods no_proxy: Comma separated list of hostnames to bypass proxy. """ - http_proxy: typing.Optional[AnyHttpUrl] - https_proxy: typing.Optional[AnyHttpUrl] - no_proxy: typing.Optional[str] + http_proxy: AnyHttpUrl | None + https_proxy: AnyHttpUrl | None + no_proxy: str | None class WazuhConfig(BaseModel): # pylint: disable=too-few-public-methods @@ -59,9 +64,9 @@ class WazuhConfig(BaseModel): # pylint: disable=too-few-public-methods custom_config_ssh_key: the secret key corresponding to the SSH key for the git repository. """ - agent_password: typing.Optional[str] = None - custom_config_repository: typing.Optional[AnyUrl] = None - custom_config_ssh_key: typing.Optional[str] = None + agent_password: str | None = None + custom_config_repository: AnyUrl | None = None + custom_config_ssh_key: str | None = None def _fetch_filebeat_configuration( @@ -139,22 +144,22 @@ def _fetch_ssh_repository_key(model: ops.Model, config: WazuhConfig) -> str | No return custom_config_ssh_key_content -def _fetch_agent_password(model: ops.Model, config: WazuhConfig) -> str | None: - """Fetch the password for the agent. +def _fetch_password(model: ops.Model, secret_id: str | None) -> str | None: + """Fetch the password for the a given secret ID. Args: model: the Juju model. - config: the charm configuration. + secret_id: the secret ID. - Returns: the SSH key for the repository, if any. + Returns: the password stored in the secret, if any. Raises: RecoverableStateError: if the secret when the key should reside is invalid. """ agent_password_content = None - if config.agent_password: + if secret_id: try: - agent_password_secret = model.get_secret(id=config.agent_password) + agent_password_secret = model.get_secret(id=secret_id) except ops.SecretNotFoundError as exc: raise RecoverableStateError("Agent secret not found.") from exc agent_password_content = agent_password_secret.get_content(refresh=True).get("value") @@ -184,13 +189,37 @@ def _fetch_cluster_key(model: ops.Model) -> str: return cluster_key_content +def _fetch_api_credentials(model: ops.Model) -> dict[str, str]: + """Fetch the Wazuh API credentials. + + Args: + model: the Juju model. + + Returns: a map containing the users and credentials for the API. + + Raises: + InvalidStateError: if the secret when the key should reside is invalid. + """ + try: + api_credentials_secret = model.get_secret(label=WAZUH_API_CREDENTIALS) + api_credentials_content = api_credentials_secret.get_content(refresh=True) + if not api_credentials_content: + raise InvalidStateError("API credentials secret is empty.") + return api_credentials_content + except ops.SecretNotFoundError: + logger.debug("Secret wazuh-api-credentials not found. Using default values.") + return WAZUH_DEFAULT_API_CREDENTIALS + + class State(BaseModel): # pylint: disable=too-few-public-methods """The Wazuh server charm state. Attributes: agent_password: the agent password. + api_credentials: a map containing the API credentials. cluster_key: the Wazuh key for the cluster nodes. indexer_ips: list of Wazuh indexer IPs. + is_default_api_password: if the default API password is in use. filebeat_username: the filebeat username. filebeat_password: the filebeat password. certificate: the TLS certificate. @@ -200,19 +229,21 @@ class State(BaseModel): # pylint: disable=too-few-public-methods proxy: proxy configuration. """ - agent_password: typing.Optional[str] = None + agent_password: str | None = None + api_credentials: dict[str, str] cluster_key: str = Field(min_length=32, max_length=32) indexer_ips: typing.Annotated[list[str], Field(min_length=1)] filebeat_username: str = Field(..., min_length=1) filebeat_password: str = Field(..., min_length=1) certificate: str = Field(..., min_length=1) root_ca: str = Field(..., min_length=1) - custom_config_repository: typing.Optional[AnyUrl] = None - custom_config_ssh_key: typing.Optional[str] = None + custom_config_repository: AnyUrl | None = None + custom_config_ssh_key: str | None = None def __init__( # pylint: disable=too-many-arguments, too-many-positional-arguments self, - agent_password: typing.Optional[str], + agent_password: str | None, + api_credentials: dict[str, str], cluster_key: str, indexer_ips: list[str], filebeat_username: str, @@ -220,12 +251,13 @@ def __init__( # pylint: disable=too-many-arguments, too-many-positional-argumen certificate: str, root_ca: str, wazuh_config: WazuhConfig, - custom_config_ssh_key: typing.Optional[str], + custom_config_ssh_key: str | None, ): """Initialize a new instance of the CharmState class. Args: agent_password: the agent password. + api_credentials: a map ccontaining the API credentials. cluster_key: the Wazuh key for the cluster nodes. indexer_ips: list of Wazuh indexer IPs. filebeat_username: the filebeat username. @@ -237,6 +269,7 @@ def __init__( # pylint: disable=too-many-arguments, too-many-positional-argumen """ super().__init__( agent_password=agent_password, + api_credentials=api_credentials, cluster_key=cluster_key, indexer_ips=indexer_ips, filebeat_username=filebeat_username, @@ -307,7 +340,8 @@ def from_charm( # pylint: disable=too-many-locals error_field_str = " ".join(f"{f}" for f in error_fields) raise RecoverableStateError(f"Invalid charm configuration {error_field_str}") from exc custom_config_ssh_key = _fetch_ssh_repository_key(charm.model, valid_config) - agent_password = _fetch_agent_password(charm.model, valid_config) + agent_password = _fetch_password(charm.model, valid_config.agent_password) + api_credentials = _fetch_api_credentials(charm.model) cluster_key = _fetch_cluster_key(charm.model) matching_certificates = _fetch_matching_certificates( provider_certificates, certitificate_signing_request @@ -316,6 +350,7 @@ def from_charm( # pylint: disable=too-many-locals if matching_certificates: return cls( agent_password=agent_password, + api_credentials=api_credentials, cluster_key=cluster_key, indexer_ips=endpoints, filebeat_username=filebeat_username, @@ -332,3 +367,11 @@ def from_charm( # pylint: disable=too-many-locals ) error_field_str = " ".join(f"{f}" for f in error_fields) raise InvalidStateError(f"Invalid charm configuration {error_field_str}") from exc + + @property + def is_default_api_password(self) -> bool: + """Check if the default API password is in use.. + + Returns: True if the current password is the default + """ + return WAZUH_DEFAULT_API_CREDENTIALS == self.api_credentials diff --git a/src/traefik_route_observer.py b/src/traefik_route_observer.py index e25ddbef..9d43b37d 100644 --- a/src/traefik_route_observer.py +++ b/src/traefik_route_observer.py @@ -18,7 +18,6 @@ PORTS: dict[str, int] = { "conn_tcp": 1514, "enrole_tcp": 1515, - "api_tcp": 55000, } diff --git a/src/wazuh.py b/src/wazuh.py index 521c74a8..d70043d4 100644 --- a/src/wazuh.py +++ b/src/wazuh.py @@ -6,27 +6,30 @@ """Wazuh operational logic.""" import logging +import secrets +import string import typing from enum import Enum from pathlib import Path from urllib.parse import urlsplit, urlunsplit import ops +import requests import yaml # Bandit classifies this import as vulnerable. For more details, see # https://github.com/PyCQA/bandit/issues/767 from lxml import etree # nosec +AGENT_PASSWORD_PATH = Path("/var/ossec/etc/authd.pass") CERTIFICATES_PATH = Path("/etc/filebeat/certs") FILEBEAT_CONF_PATH = Path("/etc/filebeat/filebeat.yml") -AGENT_PASSWORD_PATH = Path("/var/ossec/etc/authd.pass") -OSSEC_CONF_PATH = Path("/var/ossec/etc/ossec.conf") -WAZUH_USER = "wazuh" -WAZUH_GROUP = "wazuh" KNOWN_HOSTS_PATH = "/root/.ssh/known_hosts" RSA_PATH = "/root/.ssh/id_rsa" REPOSITORY_PATH = "/root/repository" +OSSEC_CONF_PATH = Path("/var/ossec/etc/ossec.conf") +WAZUH_GROUP = "wazuh" +WAZUH_USER = "wazuh" logger = logging.getLogger(__name__) @@ -36,6 +39,10 @@ class WazuhInstallationError(Exception): """Base exception for Wazuh errors.""" +class WazuhAuthenticationError(Exception): + """Wazuh authentication errors.""" + + class NodeType(Enum): """Enum for the Wazuh node types. @@ -353,3 +360,78 @@ def _generate_cluster_snippet( no """ + + +def change_api_password(username: str, old_password: str, new_password: str) -> None: + """Change Wazuh's API password for a given user. + + Args: + username: the username to change the user for. + old_password: the old API password for the user. + new_password: the new API password for the user. + + Raises: + WazuhAuthenticationError: if an authentication error occurs. + WazuhInstallationError: if an error occurs while processing the requests. + """ + # The certificates might be self signed and there's no security hardening in + # passing them to the request since tampering with `localhost` would mean the + # container filesystem is compromised + try: + response = requests.get( # nosec + "https://localhost:55000/security/user/authenticate", + auth=(username, old_password), + timeout=10, + verify=False, + ) + # The old password has already been changed. Nothing to do. + if response.status_code == 401: + raise WazuhAuthenticationError(f"The provided password for '{username}' is not valid.") + response.raise_for_status() + token = response.json()["data"]["token"] if response.json()["data"] else None + if token is None: + logger.error("Unexpected response. Auth token has not been issued.") + headers = {"Authorization": f"Bearer {token}"} + response = requests.get( # nosec + "https://localhost:55000/security/users", + headers=headers, + timeout=10, + verify=False, + ) + response.raise_for_status() + data = response.json()["data"] + user_id = [ + user["id"] for user in data["affected_items"] if data and user["username"] == username + ][0] + response = requests.put( # nosec + f"https://localhost:55000/security/users/{user_id}", + headers=headers, + json={"password": new_password}, + timeout=10, + verify=False, + ) + response.raise_for_status() + except requests.exceptions.RequestException as exc: + logger.error("Error modifying the default password: %s", exc) + logger.error("Error %s", response.json()) + raise WazuhInstallationError("Error modifying the default password.") from exc + + +def _generate_api_password() -> str: + """Generate a password that complies with the API password imposed by Wazuh. + + Returns: a string with a compliant password. + """ + alphabet = string.ascii_letters + string.digits + string.punctuation + return "".join(secrets.choice(alphabet) for _ in range(16)) + + +def generate_api_credentials() -> dict[str, str]: + """Generate the credentials for the default API users. + + Returns: a dict containing the new credentials. + """ + return { + "wazuh": _generate_api_password(), + "wazuh-wui": _generate_api_password(), + } diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 8c242f2f..19d12b16 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -6,6 +6,7 @@ import logging import os.path import secrets +import string import typing import pytest @@ -14,6 +15,8 @@ from juju.model import Controller, Model from pytest_operator.plugin import OpsTest +import state + logger = logging.getLogger(__name__) MACHINE_MODEL_CONFIG = { @@ -97,7 +100,7 @@ async def opensearch_provider_fixture( await machine_model.integrate(self_signed_certificates.name, application.name) await machine_model.create_offer(f"{application.name}:opensearch-client", application.name) await machine_model.wait_for_idle( - apps=[application.name, self_signed_certificates.name], status="active", timeout=1400 + apps=[application.name, self_signed_certificates.name], status="active", timeout=2000 ) yield application @@ -113,6 +116,14 @@ async def charm_fixture(pytestconfig: pytest.Config) -> str: return charm +@pytest.fixture(scope="module", name="api_credentials") +def api_credentials_fixture() -> dict[str, str]: + """Get Wazuh's API credentials.""" + alphabet = string.ascii_letters + string.digits + string.punctuation + password = "".join(secrets.choice(alphabet) for _ in range(16)) + return {"wazuh": password, "wazuh-wui": password} + + # pylint: disable=too-many-arguments, too-many-positional-arguments @pytest_asyncio.fixture(scope="module", name="application") async def application_fixture( @@ -122,13 +133,19 @@ async def application_fixture( opensearch_provider: Application, pytestconfig: pytest.Config, traefik: Application, + api_credentials: dict[str, str], ) -> typing.AsyncGenerator[Application, None]: """Deploy the charm.""" # Deploy the charm and wait for active/idle status resources = { "wazuh-server-image": pytestconfig.getoption("--wazuh-server-image"), } + await model.add_secret( + name=state.WAZUH_API_CREDENTIALS, + data_args=[f"{username}={password}" for username, password in api_credentials.items()], + ) application = await model.deploy(f"./{charm}", resources=resources, trust=True) + await model.grant_secret(secret_name=state.WAZUH_API_CREDENTIALS, application=application.name) await model.integrate( f"localhost:admin/{opensearch_provider.model.name}.{opensearch_provider.name}", application.name, @@ -139,6 +156,6 @@ async def application_fixture( ) await model.integrate(traefik.name, application.name) await model.wait_for_idle( - apps=[application.name, traefik.name], status="active", raise_on_error=True + apps=[application.name, traefik.name], status="active", raise_on_error=True, timeout=1500 ) yield application diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 809c10f4..d63d1bcb 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -9,6 +9,7 @@ from pathlib import Path import pytest +import requests import yaml from juju.application import Application from juju.model import Model @@ -19,17 +20,34 @@ APP_NAME = CHARMCRAFT["name"] +@pytest.mark.abort_on_fail +async def test_api(model: Model, application: Application, api_credentials: dict[str, str]): + """Deploy the charm together with related charms. + + Assert: the filebeat config is valid. + """ + status = await model.get_status() + unit = list(status.applications[application.name].units)[0] + address = status["applications"][application.name]["units"][unit]["address"] + response = requests.post( # nosec + f"https://{address}:55000/security/user/authenticate", + auth=("wazuh", api_credentials["wazuh"]), + timeout=10, + verify=False, + ) + assert response.status_code == 200 + + @pytest.mark.abort_on_fail async def test_clustering_ok(model: Model, application: Application): """Deploy the charm together with related charms and scale to two units. Assert: the clustering config is valid. """ + await application.scale(2) await model.wait_for_idle( apps=[application.name], status="active", raise_on_blocked=True, timeout=1000 ) - await application.scale(2) - await model.wait_for_idle(idle_period=30, apps=[application.name], status="active") wazuh_unit = application.units[0] # type: ignore pebble_exec = "PEBBLE_SOCKET=/charm/containers/wazuh-server/pebble.socket pebble exec" @@ -54,25 +72,3 @@ async def test_clustering_ok(model: Model, application: Application): assert code == 0, f"cluster test for unit 0 failed with code {code}: {stderr or stdout}" assert "connected nodes (1)" in stdout assert "wazuh-server-1" in stdout - - -@pytest.mark.skip("Not working yet") -@pytest.mark.abort_on_fail -async def test_filebeat_ok(model: Model, application: Application): - """Deploy the charm together with related charms. - - Assert: the filebeat config is valid. - """ - await model.wait_for_idle( - apps=[application.name], status="active", raise_on_blocked=True, timeout=1000 - ) - - wazuh_unit = application.units[0] # type: ignore - pebble_exec = "PEBBLE_SOCKET=/charm/containers/wazuh-server/pebble.socket pebble exec" - action = await wazuh_unit.run(f"{pebble_exec} -- /usr/bin/filebeat test output", timeout=10) - await action.wait() - logger.error(action.results) - code = action.results.get("return-code") - stdout = action.results.get("stdout") - stderr = action.results.get("stderr") - assert code == 0, f"filebeat test failed with code {code}: {stderr or stdout}" diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 51b13454..8cba5b2e 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -46,7 +46,7 @@ def test_invalid_state_reaches_blocked_status(state_from_charm_mock): assert harness.model.unit.status.name == ops.BlockedStatus().name -# pylint: disable=too-many-arguments, too-many-positional-arguments +# pylint: disable=too-many-arguments, too-many-locals, too-many-positional-arguments @patch.object(State, "from_charm") @patch.object(wazuh, "configure_git") @patch.object(wazuh, "pull_configuration_files") @@ -70,14 +70,21 @@ def test_reconcile_reaches_active_status_when_repository_and_password_configured """ custom_config_repository = "git+ssh://user1@git.server/repo_name@main" secret_id = f"secret:{secrets.token_hex(21)}" + api_credentials = { + "wazuh": secrets.token_hex(), + "wazuh-wui": secrets.token_hex(), + } wazuh_config = WazuhConfig( - custom_config_repository=custom_config_repository, custom_config_ssh_key=secret_id + api_credentials=api_credentials, + custom_config_repository=custom_config_repository, + custom_config_ssh_key=secret_id, ) password = secrets.token_hex() agent_password = secrets.token_hex() cluster_key = secrets.token_hex(16) state_from_charm_mock.return_value = State( agent_password=agent_password, + api_credentials=api_credentials, cluster_key=cluster_key, certificate="somecert", root_ca="root_ca", @@ -139,16 +146,25 @@ def test_reconcile_reaches_active_status_when_repository_and_password_not_config assert: the charm reaches active status and configs are applied. """ password = secrets.token_hex() + api_credentials = { + "wazuh": secrets.token_hex(), + "wazuh-wui": secrets.token_hex(), + } cluster_key = secrets.token_hex(16) state_from_charm_mock.return_value = State( agent_password=None, + api_credentials=api_credentials, cluster_key=cluster_key, certificate="somecert", root_ca="root_ca", indexer_ips=["10.0.0.1"], filebeat_username="user1", filebeat_password=password, - wazuh_config=WazuhConfig(custom_config_repository=None, custom_config_ssh_key=None), + wazuh_config=WazuhConfig( + api_credentials=api_credentials, + custom_config_repository=None, + custom_config_ssh_key=None, + ), custom_config_ssh_key=None, ) harness = Harness(WazuhServerCharm) diff --git a/tests/unit/test_state.py b/tests/unit/test_state.py index c563cf22..98ba405d 100644 --- a/tests/unit/test_state.py +++ b/tests/unit/test_state.py @@ -29,6 +29,8 @@ def test_state_invalid_relation_data(opensearch_relation_data): assert: a InvalidStateError is raised. """ mock_charm = unittest.mock.MagicMock(spec=ops.CharmBase) + secret_id = f"secret:{secrets.token_hex()}" + mock_charm.config = {"wazuh-api-credentials": secret_id} provider_certificates = [ certificates.ProviderCertificate( relation_id="certificates-provider/1", @@ -55,6 +57,8 @@ def test_state_without_proxy(): assert: the state contains the endpoints. """ mock_charm = unittest.mock.MagicMock(spec=ops.CharmBase) + secret_id = f"secret:{secrets.token_hex()}" + mock_charm.config = {"wazuh-api-credentials": secret_id} endpoints = ["10.0.0.1", "10.0.0.2"] username = "user1" password = secrets.token_hex() @@ -85,6 +89,8 @@ def test_state_without_proxy(): mock_charm, opensearch_relation_data, provider_certificates, csr ) + assert charm_state.api_credentials is not None + assert charm_state.api_credentials["value"] == value assert charm_state.cluster_key == value assert charm_state.indexer_ips == endpoints assert charm_state.filebeat_username == username @@ -96,6 +102,7 @@ def test_state_without_proxy(): assert charm_state.proxy.http_proxy is None assert charm_state.proxy.https_proxy is None assert charm_state.proxy.no_proxy is None + assert not charm_state.is_default_api_password def test_state_with_proxy(monkeypatch: pytest.MonkeyPatch): @@ -105,6 +112,8 @@ def test_state_with_proxy(monkeypatch: pytest.MonkeyPatch): assert: the state contains the endpoints. """ mock_charm = unittest.mock.MagicMock(spec=ops.CharmBase) + secret_id = f"secret:{secrets.token_hex()}" + mock_charm.config = {"wazuh-api-credentials": secret_id} endpoints = ["10.0.0.1", "10.0.0.2"] username = "user1" password = secrets.token_hex() @@ -138,7 +147,8 @@ def test_state_with_proxy(monkeypatch: pytest.MonkeyPatch): charm_state = state.State.from_charm( mock_charm, opensearch_relation_data, provider_certificates, csr ) - + assert charm_state.api_credentials is not None + assert charm_state.api_credentials["value"] == value assert charm_state.cluster_key == value assert charm_state.indexer_ips == endpoints assert charm_state.certificate == certificate @@ -150,6 +160,7 @@ def test_state_with_proxy(monkeypatch: pytest.MonkeyPatch): assert str(charm_state.proxy.http_proxy) == "http://squid.proxy:3228/" assert str(charm_state.proxy.https_proxy) == "https://squid.proxy:3228/" assert charm_state.proxy.no_proxy == "localhost" + assert not charm_state.is_default_api_password def test_proxyconfig_invalid(monkeypatch: pytest.MonkeyPatch): @@ -160,7 +171,8 @@ def test_proxyconfig_invalid(monkeypatch: pytest.MonkeyPatch): """ monkeypatch.setenv("JUJU_CHARM_HTTP_PROXY", "INVALID_URL") mock_charm = unittest.mock.MagicMock(spec=ops.CharmBase) - mock_charm.config = {} + secret_id = f"secret:{secrets.token_hex()}" + mock_charm.config = {"wazuh-api-credentials": secret_id} endpoints = ["10.0.0.1", "10.0.0.2"] username = "user1" @@ -204,10 +216,12 @@ def test_state_when_repository_secret_not_found(monkeypatch: pytest.MonkeyPatch) mock_charm = unittest.mock.MagicMock(spec=ops.CharmBase) repository_secret_id = f"secret:{secrets.token_hex()}" mock_charm.model.get_secret(id=repository_secret_id).side_effect = ops.SecretNotFoundError + secret_id = f"secret:{secrets.token_hex()}" monkeypatch.setattr( mock_charm, "config", { + "wazuh-api-credentials": secret_id, "custom-config-repository": "git+ssh://user1@git.server/repo_name@main", "custom-config-ssh-key": repository_secret_id, }, @@ -219,7 +233,6 @@ def test_state_when_repository_secret_not_found(monkeypatch: pytest.MonkeyPatch) opensearch_relation_data = {"endpoints": ",".join(endpoints)} certificate = "somecert" root_ca = "someca" - secret_id = f"secret:{secrets.token_hex()}" mock_charm.model.get_secret(id=secret_id).get_content.return_value = { "username": username, "password": password, @@ -255,6 +268,7 @@ def test_state_when_agent_password_secret_not_found(monkeypatch: pytest.MonkeyPa "config", { "agent-password": secret_id, + "wazuh-api-credentials": secret_id, }, ) @@ -295,10 +309,12 @@ def test_state_when_repository_secret_invalid(monkeypatch: pytest.MonkeyPatch): mock_charm = unittest.mock.MagicMock(spec=ops.CharmBase) repository_secret_id = f"secret:{secrets.token_hex()}" mock_charm.model.get_secret(id=repository_secret_id).return_value.get_content.return_value = {} + secret_id = f"secret:{secrets.token_hex()}" monkeypatch.setattr( mock_charm, "config", { + "wazuh-api-credentials": secret_id, "custom-config-repository": "git+ssh://user1@git.server/repo_name@main", "custom-config-ssh-key": repository_secret_id, }, @@ -310,7 +326,6 @@ def test_state_when_repository_secret_invalid(monkeypatch: pytest.MonkeyPatch): opensearch_relation_data = {"endpoints": ",".join(endpoints)} certificate = "somecert" root_ca = "someca" - secret_id = f"secret:{secrets.token_hex()}" mock_charm.model.get_secret(id=secret_id).get_content.return_value = { "username": username, "password": password, @@ -347,6 +362,7 @@ def test_state_when_agent_secret_invalid(monkeypatch: pytest.MonkeyPatch): "config", { "agent-password": secret_id, + "wazuh-api-credentials": secret_id, }, ) @@ -388,10 +404,13 @@ def test_state_when_repository_secret_valid(monkeypatch: pytest.MonkeyPatch): custom_config_repository = "git+ssh://user1@git.server/repo_name@main" mock_charm = unittest.mock.MagicMock(spec=ops.CharmBase) repository_secret_id = f"secret:{secrets.token_hex()}" + secret_id = f"secret:{secrets.token_hex()}" + value = secrets.token_hex(16) monkeypatch.setattr( mock_charm, "config", { + "wazuh-api-credentials": value, "custom-config-repository": custom_config_repository, "custom-config-ssh-key": repository_secret_id, }, @@ -453,6 +472,7 @@ def test_state_when_agent_password_secret_valid(monkeypatch: pytest.MonkeyPatch) "config", { "agent-password": secret_id, + "wazuh-api-credentials": secret_id, }, ) diff --git a/tests/unit/test_traefik_route_observer.py b/tests/unit/test_traefik_route_observer.py index fc29961a..4e06f814 100644 --- a/tests/unit/test_traefik_route_observer.py +++ b/tests/unit/test_traefik_route_observer.py @@ -68,12 +68,6 @@ def test_on_traefik_route_relation_joined_when_leader(monkeypatch: pytest.Monkey "rule": "HostSNI(`*`)", "tls": {"passthrough": True}, }, - "juju-testing-observer-charm-api-tcp": { - "entryPoints": ["api-tcp"], - "service": "juju-testing-observer-charm-service-api-tcp", - "rule": "HostSNI(`*`)", - "tls": {"passthrough": True}, - }, }, "services": { "juju-testing-observer-charm-service-conn-tcp": { @@ -82,9 +76,6 @@ def test_on_traefik_route_relation_joined_when_leader(monkeypatch: pytest.Monkey "juju-testing-observer-charm-service-enrole-tcp": { "loadBalancer": {"servers": [{"address": "wazuh-server.local:1515"}]} }, - "juju-testing-observer-charm-service-api-tcp": { - "loadBalancer": {"servers": [{"address": "wazuh-server.local:55000"}]} - }, }, }, }, @@ -92,8 +83,7 @@ def test_on_traefik_route_relation_joined_when_leader(monkeypatch: pytest.Monkey "entryPoints": { "conn-tcp": {"address": ":1514"}, "enrole-tcp": {"address": ":1515"}, - "api-tcp": {"address": ":55000"}, - }, + } }, ) diff --git a/tests/unit/test_wazuh.py b/tests/unit/test_wazuh.py index 5aa462a2..bf740fb9 100644 --- a/tests/unit/test_wazuh.py +++ b/tests/unit/test_wazuh.py @@ -322,3 +322,15 @@ def test_configure_git_when_no_key_no_repository_specified() -> None: wazuh.configure_git(container, None, None) assert not container.exists(wazuh.KNOWN_HOSTS_PATH) assert not container.exists(wazuh.RSA_PATH) + + +def test_generate_api_credentials() -> None: + """ + arrange: do nothing. + act: generate API credentials. + assert: a map with the 'wazuh' and 'wazuh-wui' credentials is returned. + """ + credentials = wazuh.generate_api_credentials() + + assert "wazuh" in credentials + assert "wazuh-wui" in credentials diff --git a/trivy.yaml b/trivy.yaml index 362519c6..19530af9 100644 --- a/trivy.yaml +++ b/trivy.yaml @@ -4,3 +4,4 @@ scan: # Ignore TLS cert installed by Wazuh skip-files: - /var/ossec/etc/sslmanager.key + - /var/ossec/framework/python/lib/python3.10/site-packages/google/auth/crypt/__pycache__/_python_rsa.cpython-310.pyc