From d92de14192cf2832bba6c895ffcb1464072306dc Mon Sep 17 00:00:00 2001 From: Prageeth Panicker Date: Wed, 7 Oct 2026 08:28:15 +0000 Subject: [PATCH] fix(web-app-wazuh): use a stable salt for the Wazuh password hashes bcrypt_hash takes an optional salt_seed. Without a seed it keeps the random salt. With a seed the salt is derived from the seed and the secret, so the same password always gives the same hash. Wazuh passes a fixed seed for the indexer admin and the dashboard service hashes, so internal_users.yml no longer changes on every run and no longer triggers a restart. --- plugins/filter/bcrypt_hash.py | 35 +++++++++++++++++-- roles/web-app-wazuh/vars/main.yml | 4 +-- .../python/plugins/filter/test_bcrypt_hash.py | 33 +++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/plugins/filter/bcrypt_hash.py b/plugins/filter/bcrypt_hash.py index 3376efad35..e5e26fde2c 100644 --- a/plugins/filter/bcrypt_hash.py +++ b/plugins/filter/bcrypt_hash.py @@ -12,8 +12,32 @@ # such self-test and hashes correctly in the same environment. from __future__ import annotations +import base64 +import hashlib + import bcrypt +_STD_TO_BCRYPT_B64 = bytes.maketrans( + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", + b"./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", +) + + +def _seeded_salt(seed: str, secret: bytes) -> bytes: + """A fixed bcrypt salt derived from the seed and the secret. + + Args: + seed: caller-chosen, non-empty string that separates otherwise equal secrets. + secret: the UTF-8 encoded secret being hashed. + + Returns: + ``$2b$12$`` plus 22 bcrypt-base64 characters encoding the first 16 + bytes of sha256(seed + secret). + """ + digest = hashlib.sha256(seed.encode("utf-8") + secret).digest()[:16] + encoded = base64.b64encode(digest)[:22].translate(_STD_TO_BCRYPT_B64) + return b"$2b$12$" + encoded + class FilterModule: def filters(self): @@ -22,7 +46,7 @@ class FilterModule: } @staticmethod - def bcrypt_hash(value): + def bcrypt_hash(value, salt_seed=None): if not isinstance(value, str) or not value: raise ValueError("bcrypt_hash: value must be a non-empty string") @@ -34,4 +58,11 @@ class FilterModule: "letting bcrypt truncate or reject it." ) - return bcrypt.hashpw(secret, bcrypt.gensalt()).decode("utf-8") + if salt_seed is None: + salt = bcrypt.gensalt() + elif isinstance(salt_seed, str) and salt_seed: + salt = _seeded_salt(salt_seed, secret) + else: + raise ValueError("bcrypt_hash: salt_seed must be a non-empty string when given") + + return bcrypt.hashpw(secret, salt).decode("utf-8") diff --git a/roles/web-app-wazuh/vars/main.yml b/roles/web-app-wazuh/vars/main.yml index e48f577f9a..187e7a30a7 100644 --- a/roles/web-app-wazuh/vars/main.yml +++ b/roles/web-app-wazuh/vars/main.yml @@ -49,11 +49,11 @@ WAZUH_LDAP_ENABLED: "{{ lookup('config', application_id, 'services.ldap.enabled' WAZUH_INDEXER_ADMIN_USERNAME: "{{ lookup('users', 'admin').username }}" WAZUH_INDEXER_ADMIN_PASSWORD: "{{ lookup('config', application_id, 'secrets.credentials.indexer_admin_password') }}" -WAZUH_INDEXER_ADMIN_HASH: "{{ WAZUH_INDEXER_ADMIN_PASSWORD | bcrypt_hash }}" +WAZUH_INDEXER_ADMIN_HASH: "{{ WAZUH_INDEXER_ADMIN_PASSWORD | bcrypt_hash(application_id ~ ':indexer-admin') }}" WAZUH_DASHBOARD_SERVICE_USERNAME: "{{ lookup('users', 'web-app-wazuh-dashboard').username }}" WAZUH_DASHBOARD_SERVICE_PASSWORD: "{{ lookup('config', application_id, 'secrets.credentials.dashboard_service_password') }}" -WAZUH_DASHBOARD_SERVICE_HASH: "{{ WAZUH_DASHBOARD_SERVICE_PASSWORD | bcrypt_hash }}" +WAZUH_DASHBOARD_SERVICE_HASH: "{{ WAZUH_DASHBOARD_SERVICE_PASSWORD | bcrypt_hash(application_id ~ ':dashboard-service') }}" WAZUH_WUI_USERNAME: "{{ lookup('users', 'web-app-wazuh-api').username }}" WAZUH_WUI_PASSWORD: "{{ lookup('config', application_id, 'secrets.credentials.wazuh_wui_password') }}" diff --git a/tests/unit/python/plugins/filter/test_bcrypt_hash.py b/tests/unit/python/plugins/filter/test_bcrypt_hash.py index c87290afaa..de6de1ae67 100644 --- a/tests/unit/python/plugins/filter/test_bcrypt_hash.py +++ b/tests/unit/python/plugins/filter/test_bcrypt_hash.py @@ -52,6 +52,39 @@ class TestBcryptHash(unittest.TestCase): with self.assertRaisesRegex(ValueError, "74 bytes"): self.f(password) + def test_same_secret_and_seed_give_the_same_hash(self): + self.assertEqual( + self.f("same-password", "app:indexer-admin"), + self.f("same-password", "app:indexer-admin"), + ) + + def test_different_seed_gives_a_different_hash(self): + self.assertNotEqual( + self.f("same-password", "app:indexer-admin"), + self.f("same-password", "app:dashboard-service"), + ) + + def test_different_secret_gives_a_different_hash(self): + self.assertNotEqual( + self.f("password-one", "app:indexer-admin"), + self.f("password-two", "app:indexer-admin"), + ) + + def test_seeded_hash_verifies_against_the_original_password(self): + password = "correct horse battery staple" + hashed = self.f(password, "app:indexer-admin") + self.assertTrue( + bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8")) + ) + self.assertTrue(hashed.startswith("$2b$12$")) + + def test_no_seed_stays_randomized(self): + self.assertNotEqual(self.f("same-password"), self.f("same-password")) + + def test_empty_seed_raises(self): + with self.assertRaises(ValueError): + self.f("some-password", "") + def test_none_raises(self): with self.assertRaises(ValueError): self.f(None)