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