diff --git a/README.md b/README.md index 02b6edcf3d..8654488f29 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,7 @@ Every invokable role in the tested lifecycle envelope, with its upstream homepag | [Typesense](roles/svc-db-typesense/) | beta | Central Dockerized search engine shared by multiple apps with per-consumer scoped API keys | [๐ŸŒ](https://typesense.org/) [๐Ÿ› ๏ธ](roles/svc-db-typesense/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Tor Onion Service](roles/svc-net-tor/) | | [Unbound](roles/svc-dns-unbound/) | beta | Central recursive/forwarding DNSSEC resolver on a static IP for consumer stacks to target as their DNS | [๐ŸŒ](https://nlnetlabs.nl/projects/unbound/about/) [๐Ÿ› ๏ธ](roles/svc-dns-unbound/README.md#quick-setup) | | | [Universal Logout](roles/web-svc-logout/) | beta | Central logout proxy for single-action sign-out across all OIDC-connected apps, with Nginx snippets and a live status UI | [๐Ÿ› ๏ธ](roles/web-svc-logout/README.md#quick-setup) | [Corporate Design](roles/web-svc-css/), [Matomo](roles/web-app-matomo/), [Prometheus](roles/web-app-prometheus/), [Tor Onion Service](roles/svc-net-tor/) | +| [Wazuh](roles/web-app-wazuh/) | beta | Open-source SIEM and XDR platform for monitoring, log analysis, intrusion/vulnerability detection, and compliance | [๐ŸŒ](https://wazuh.com/) [๐ŸŽฌ](https://www.youtube.com/watch?v=peTSzcAueEc) [๐Ÿ› ๏ธ](roles/web-app-wazuh/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Keycloak](roles/web-app-keycloak/), [Matomo](roles/web-app-matomo/), [OpenLDAP](roles/svc-db-openldap/), [Prometheus](roles/web-app-prometheus/), [Tor Onion Service](roles/svc-net-tor/), [Universal Logout](roles/web-svc-logout/) | | [web-app-pihole](roles/web-app-pihole/) | beta | Network-wide DNS sinkhole for blocking ads and trackers across all devices, with DHCP and a web admin dashboard | [๐ŸŒ](https://pi-hole.net/) [๐ŸŽฌ](https://youtu.be/KBXTnrD_Zs4) [๐Ÿ› ๏ธ](roles/web-app-pihole/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Content Delivery Network](roles/web-svc-cdn/), [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Keycloak](roles/web-app-keycloak/), [Matomo](roles/web-app-matomo/), [Prometheus](roles/web-app-prometheus/), [Tor Onion Service](roles/svc-net-tor/), [Universal Logout](roles/web-svc-logout/) | | [WireGuard](roles/svc-net-wireguard/) | beta | Kernel WireGuard tunnels, hub-and-spoke meshes under a swarm manager and full meshes between Compose hosts | [๐ŸŒ](https://www.wireguard.com/) [๐Ÿ› ๏ธ](roles/svc-net-wireguard/README.md#quick-setup) | | | [WordPress](roles/web-app-wordpress/) | beta | Content management system for building and managing websites, blogs, and multisite networks with themes and plugins | [๐ŸŒ](https://wordpress.org/) [๐ŸŽฌ](https://www.youtube.com/watch?v=QU8GFqvXRxE) [๐Ÿ› ๏ธ](roles/web-app-wordpress/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Discourse](roles/web-app-discourse/), [Hermes Agent](roles/web-app-hermes/), [Keycloak](roles/web-app-keycloak/), [LiteLLM Gateway](roles/svc-ai-litellm/), [MariaDB](roles/svc-db-mariadb/), [Matomo](roles/web-app-matomo/), [OpenClaw](roles/web-app-openclaw/), [Prometheus](roles/web-app-prometheus/), [Stalwart Mail Server](roles/web-app-stalwart/), [Tor Onion Service](roles/svc-net-tor/), [Universal Logout](roles/web-svc-logout/) | diff --git a/docs/requirements/035-mcp-proxy-expansion.md b/docs/requirements/035-mcp-proxy-expansion.md index 7df7fa46bf..c2c33155f0 100644 --- a/docs/requirements/035-mcp-proxy-expansion.md +++ b/docs/requirements/035-mcp-proxy-expansion.md @@ -464,11 +464,11 @@ An upstream path exists or is claimed, and something concrete stops it here. Eac | `web-app-penpot` | `interactive_browser_session` | The server needs an active browser tab and plugin connection, and can execute powerful design-context operations. | | `web-app-shopware` | `version_unverified` | The pinned 6.7.8.2 predates the experimental native `/api/_mcp` server. | -### adapter_candidate (38) +### adapter_candidate (39) An adapter could reach these, and none has been curated yet. The five that used to sit under "current MCP metadata requiring revalidation" are here because that revalidation found no shipped surface to revalidate. -`svc-db-typesense`, `web-app-akaunting`, `web-app-bigbluebutton`, `web-app-bluesky`, `web-app-bookwyrm`, `web-app-bridgy-fed`, `web-app-confluence`, `web-app-decidim`, `web-app-espocrm`, `web-app-friendica`, `web-app-funkwhale`, `web-app-jira`, `web-app-jitsi`, `web-app-joomla`, `web-app-kix`, `web-app-magento`, `web-app-mailu`, `web-app-mastodon`, `web-app-matrix`, `web-app-mediawiki`, `web-app-minio`, `web-app-mobilizon`, `web-app-opencloud`, `web-app-openproject`, `web-app-opentalk`, `web-app-peertube`, `web-app-pihole`, `web-app-pixelfed`, `web-app-postmarks`, `web-app-seaweedfs`, `web-app-semaphore`, `web-app-socialhome`, `web-app-stalwart`, `web-app-suitecrm`, `web-app-taiga`, `web-app-xwiki`, `web-app-yourls`, `web-svc-xmpp`. +`svc-db-typesense`, `web-app-akaunting`, `web-app-bigbluebutton`, `web-app-bluesky`, `web-app-bookwyrm`, `web-app-bridgy-fed`, `web-app-confluence`, `web-app-decidim`, `web-app-espocrm`, `web-app-friendica`, `web-app-funkwhale`, `web-app-jira`, `web-app-jitsi`, `web-app-joomla`, `web-app-kix`, `web-app-magento`, `web-app-mailu`, `web-app-mastodon`, `web-app-matrix`, `web-app-mediawiki`, `web-app-minio`, `web-app-mobilizon`, `web-app-opencloud`, `web-app-openproject`, `web-app-opentalk`, `web-app-peertube`, `web-app-pihole`, `web-app-pixelfed`, `web-app-postmarks`, `web-app-seaweedfs`, `web-app-semaphore`, `web-app-socialhome`, `web-app-stalwart`, `web-app-suitecrm`, `web-app-taiga`, `web-app-wazuh`, `web-app-xwiki`, `web-app-yourls`, `web-svc-xmpp`. ### enabler (2) diff --git a/group_vars/all/11_oidc.yml b/group_vars/all/11_oidc.yml index 8c3c1b5e3f..a6274d461b 100644 --- a/group_vars/all/11_oidc.yml +++ b/group_vars/all/11_oidc.yml @@ -48,4 +48,9 @@ defaults_oidc: FAMILY_NAME: "surname" EMAIL: "email" + # Keycloak's own built-in admin realm/client (password-grant Admin REST API automation), distinct from the app's own CLIENT.REALM/CLIENT.ID above. + ADMIN: + REALM: "master" + CLI_CLIENT_ID: "admin-cli" + OIDC: "{{ defaults_oidc | combine(OIDC_OVERRIDE | default({}, true), recursive=True) }}" diff --git a/plugins/filter/bcrypt_hash.py b/plugins/filter/bcrypt_hash.py new file mode 100644 index 0000000000..2f9d5e91e1 --- /dev/null +++ b/plugins/filter/bcrypt_hash.py @@ -0,0 +1,70 @@ +# Bcrypt hashing via the raw `bcrypt` package, bypassing Ansible's built-in +# `password_hash('bcrypt')` filter. +# +# `password_hash('bcrypt')` routes through passlib, whose bcrypt backend runs +# a self-test (`detect_wrap_bug`) against a fixed known-answer secret on +# first use. In environments where the installed `bcrypt` package and +# passlib's bundled version-detection drift apart (`bcrypt` has no +# `__about__.__version__` attribute passlib expects), that self-test itself +# raises "password cannot be longer than 72 bytes" โ€” a misleading error +# unrelated to the caller's actual input, and one that makes the filter +# unusable for ANY input, not just long ones. The raw `bcrypt` package has no +# 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): + return { + "bcrypt_hash": self.bcrypt_hash, + } + + @staticmethod + 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") + + secret = value.encode("utf-8") + if len(secret) > 72: + raise ValueError( + f"bcrypt_hash: secret is {len(secret)} bytes in UTF-8; bcrypt " + "accepts at most 72 bytes. Shorten the secret instead of " + "letting bcrypt truncate or reject it." + ) + + 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/test-e2e-playwright/files/personas/utils/keycloak.js b/roles/test-e2e-playwright/files/personas/utils/keycloak.js index ff499b5a67..cb4a2c75fb 100644 --- a/roles/test-e2e-playwright/files/personas/utils/keycloak.js +++ b/roles/test-e2e-playwright/files/personas/utils/keycloak.js @@ -1,5 +1,7 @@ /** - * Keycloak OIDC login helpers. + * Keycloak OIDC login helpers, plus Keycloak Admin REST API helpers for + * Playwright specs that need to manipulate group membership directly + * (RBAC-tier tests across web-app-* roles). * * `performKeycloakLoginForm(target, username, password)` * Fills the Keycloak login form on `target` (a `Page` OR a @@ -23,6 +25,21 @@ * or a 401 / 403 on the relying party after the callback). * Returns the resulting URL so callers can assert additional * details if needed. + * + * `keycloakAdminToken(request, keycloakBaseUrl, username, password, opts)` + * Password-grant token fetch against Keycloak's own bootstrap realm + * (default `master`/`admin-cli`, both overridable via `opts` since + * they are deployment-configurable, not framework constants). + * + * `keycloakResolveGroupId(request, keycloakBaseUrl, realmName, accessToken, groupPath)` + * Resolves a group's internal id from its human-readable path. + * + * `keycloakAdminAddUserToGroup(request, keycloakBaseUrl, realmName, targetGroupPath, username, adminUsername, adminPassword, opts)` + * Idempotent group join; returns whether this call performed it. + * + * `keycloakRemoveUserFromGroupViaRest(request, keycloakBaseUrl, realmName, adminUsername, adminPassword, groupPath, username, opts)` + * Best-effort group removal (throws on lookup failure, no-ops if the + * user/group is already gone). */ const { expect } = require("@playwright/test"); @@ -178,10 +195,206 @@ async function performKeycloakLoginExpectingDenial(page, username, password, can return finalUrl; } +async function keycloakAdminToken( + request, + keycloakBaseUrl, + username, + password, + { adminRealm = "master", adminClientId = "admin-cli" } = {}, +) { + const tokenResp = await request.post( + `${keycloakBaseUrl}/realms/${encodeURIComponent(adminRealm)}/protocol/openid-connect/token`, + { + form: { + client_id: adminClientId, + grant_type: "password", + username, + password, + }, + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }, + ); + if (!tokenResp.ok()) { + throw new Error( + `Keycloak admin token request failed: ${tokenResp.status()} ${await tokenResp.text()}`, + ); + } + const json = await tokenResp.json(); + if (!json.access_token) { + throw new Error("Keycloak admin token response missing access_token"); + } + return json.access_token; +} + +// Resolves a group's internal Keycloak id from its human-readable path +// (e.g. "/roles/web-app-wazuh/administrator"). Tries the direct +// group-by-path admin endpoint first, since it is a single request; some +// Keycloak versions 404 that endpoint for paths containing certain +// characters, so on failure this falls back to walking the group tree one +// path segment at a time (list top-level groups filtered by name, then +// each subsequent segment's children), matching by exact name at each +// depth, and returns the id of the final matched segment. +async function keycloakResolveGroupId(request, keycloakBaseUrl, realmName, accessToken, groupPath) { + const headers = { Authorization: `Bearer ${accessToken}` }; + const trimmed = groupPath.replace(/^\//, ""); + const byPath = await request.get( + `${keycloakBaseUrl}/admin/realms/${encodeURIComponent(realmName)}/group-by-path/${trimmed}`, + { headers }, + ); + if (byPath.ok()) { + const group = await byPath.json(); + if (group?.id) return group.id; + } + const segments = trimmed.split("/").filter((s) => s !== ""); + if (segments.length === 0) { + throw new Error(`Empty Keycloak group path: ${groupPath}`); + } + let parentId = null; + for (let i = 0; i < segments.length; i++) { + const wanted = segments[i]; + const url = + parentId === null + ? `${keycloakBaseUrl}/admin/realms/${realmName}/groups?max=500&search=${encodeURIComponent(wanted)}` + : `${keycloakBaseUrl}/admin/realms/${realmName}/groups/${parentId}/children?max=500`; + const resp = await request.get(url, { headers }); + if (!resp.ok()) { + throw new Error( + `Keycloak groups lookup failed at segment ${i} (${wanted}): ${resp.status()} ${await resp.text()}`, + ); + } + const items = await resp.json(); + let match; + if (parentId === null) { + // The top-level search endpoint returns full subtrees, not just + // direct matches, so walk each returned tree looking for `wanted` + // at depth 0 only - a same-named descendant deeper in the tree + // MUST NOT be mistaken for the top-level segment. + const walk = (nodes, depth) => { + for (const n of nodes) { + if (depth === 0 && n.name === wanted) return n; + if (n.subGroups && n.subGroups.length) { + const r = walk(n.subGroups, depth - 1); + if (r) return r; + } + } + return null; + }; + match = walk(items, 0); + } else { + match = items.find((n) => n.name === wanted) || null; + } + if (!match) { + throw new Error(`Keycloak group "${groupPath}" not found while resolving segment "${wanted}"`); + } + parentId = match.id; + } + return parentId; +} + +// Adds `username` to the group at `targetGroupPath`, fetching its own +// admin token internally. Returns true when this call performed the join +// (caller MUST tear down), false when the user was already a member +// (caller MUST NOT remove it, since removal would then affect membership +// this call did not grant). +async function keycloakAdminAddUserToGroup( + request, + keycloakBaseUrl, + realmName, + targetGroupPath, + username, + adminUsername, + adminPassword, + opts = {}, +) { + const accessToken = await keycloakAdminToken(request, keycloakBaseUrl, adminUsername, adminPassword, opts); + const headers = { Authorization: `Bearer ${accessToken}` }; + + const userResp = await request.get( + `${keycloakBaseUrl}/admin/realms/${realmName}/users?username=${encodeURIComponent(username)}&exact=true`, + { headers }, + ); + if (!userResp.ok()) { + throw new Error(`Keycloak user lookup failed: ${userResp.status()} ${await userResp.text()}`); + } + const users = await userResp.json(); + const user = users.find((u) => u.username === username); + if (!user) { + throw new Error(`Keycloak user "${username}" not found`); + } + + const groupId = await keycloakResolveGroupId(request, keycloakBaseUrl, realmName, accessToken, targetGroupPath); + + const memberResp = await request.get( + `${keycloakBaseUrl}/admin/realms/${realmName}/users/${user.id}/groups?max=500`, + { headers }, + ); + if (!memberResp.ok()) { + throw new Error(`Keycloak user-groups lookup failed: ${memberResp.status()} ${await memberResp.text()}`); + } + const currentGroups = await memberResp.json(); + if (currentGroups.some((g) => g.id === groupId || g.path === targetGroupPath)) { + return false; + } + + const joinResp = await request.put( + `${keycloakBaseUrl}/admin/realms/${realmName}/users/${user.id}/groups/${groupId}`, + { headers }, + ); + if (!joinResp.ok()) { + throw new Error( + `Keycloak join-group failed (user=${username}, group=${targetGroupPath}): ${joinResp.status()} ${await joinResp.text()}`, + ); + } + return true; +} + +async function keycloakRemoveUserFromGroupViaRest( + request, + keycloakBaseUrl, + realmName, + adminUsername, + adminPassword, + groupPath, + username, + opts = {}, +) { + const accessToken = await keycloakAdminToken(request, keycloakBaseUrl, adminUsername, adminPassword, opts); + const headers = { Authorization: `Bearer ${accessToken}` }; + + const userResp = await request.get( + `${keycloakBaseUrl}/admin/realms/${realmName}/users?username=${encodeURIComponent(username)}&exact=true`, + { headers }, + ); + if (!userResp.ok()) { + throw new Error(`Keycloak user lookup failed: ${userResp.status()} ${await userResp.text()}`); + } + const users = await userResp.json(); + const user = users.find((u) => u.username === username); + if (!user) { + throw new Error(`Keycloak user "${username}" not found`); + } + + const groupId = await keycloakResolveGroupId(request, keycloakBaseUrl, realmName, accessToken, groupPath); + + const leaveResp = await request.delete( + `${keycloakBaseUrl}/admin/realms/${realmName}/users/${user.id}/groups/${groupId}`, + { headers }, + ); + if (!leaveResp.ok()) { + throw new Error( + `Keycloak leave-group failed (user=${username}, group=${groupPath}): ${leaveResp.status()} ${await leaveResp.text()}`, + ); + } +} + module.exports = { OIDC_LOGIN_READY_FLAG, performKeycloakLoginForm, performKeycloakLogin, clickOidcLoginLink, performKeycloakLoginExpectingDenial, + keycloakAdminToken, + keycloakResolveGroupId, + keycloakAdminAddUserToGroup, + keycloakRemoveUserFromGroupViaRest, }; diff --git a/roles/test-e2e-playwright/files/personas/utils/logout.js b/roles/test-e2e-playwright/files/personas/utils/logout.js index 292a49b2ba..7b3d9a6b2e 100644 --- a/roles/test-e2e-playwright/files/personas/utils/logout.js +++ b/roles/test-e2e-playwright/files/personas/utils/logout.js @@ -82,7 +82,11 @@ function menuTriggerCandidatesOn(scope) { scope.getByRole("button", { name: ACCOUNT_MENU_NAME_RE }), scope.getByRole("link", { name: ACCOUNT_MENU_NAME_RE }), scope.locator( - "[data-bs-toggle='dropdown'], .dropdown-toggle, [aria-haspopup='menu'], [aria-haspopup='true'], [data-region='user-menu-toggle'], .user-menu-toggle, .usermenu, [aria-label*='user menu' i], [aria-label*='account' i], [data-testid*='user' i]:not(input):not(textarea):not(select)", + + // #user-icon-btn: OpenSearch/Kibana Dashboards' user-avatar trigger has no accessible name or aria-haspopup, so it needs an explicit id selector. + "[data-bs-toggle='dropdown'], .dropdown-toggle, [aria-haspopup='menu'], [aria-haspopup='true'], [data-region='user-menu-toggle'], .user-menu-toggle, .usermenu, [aria-label*='user menu' i], [aria-label*='account' i], [data-testid*='user' i]:not(input):not(textarea):not(select), #user-icon-btn", + + ), ]; } diff --git a/roles/web-app-wazuh/README.md b/roles/web-app-wazuh/README.md new file mode 100644 index 0000000000..365e7ea32c --- /dev/null +++ b/roles/web-app-wazuh/README.md @@ -0,0 +1,174 @@ +# Wazuh + +## Description + +[Wazuh](https://wazuh.com/) is an open-source SIEM and XDR platform providing security monitoring, log analysis, intrusion and vulnerability detection, and compliance support. + +## Overview + +This role deploys the single-node Wazuh stack (`wazuh.manager`, `wazuh.indexer`, `wazuh.dashboard`) with Docker Compose or Docker Swarm. +A one-shot `wazuh-certs-generator` job creates the internal TLS material before the manager and the indexer start. +The indexer is the stack's own datastore and holds the OpenSearch security configuration, so the role needs no shared database. +Agent enrollment is not part of this role: the agent ports are declared in [services.yml](./meta/services.yml) but not published. +The manager's vulnerability detection data (`/var/ossec/queue/vd`, volume `wazuh_manager_vd`) is deliberately not backed up. +The manager downloads it again after a restore, which can take a long time. +This matters once agents are enrolled. + +## Cosmos + +The diagram places Wazuh in the Infinito.Nexus cosmos: the components it deploys (capabilities), the central services it consumes (dependencies), and its outward reach (federation and bridged external networks). + +```mermaid +flowchart LR + subgraph deps [Dependencies] + dep_svc_bkp_volume_2_local["svc-bkp-volume-2-local ๐Ÿ’ป"] + dep_svc_db_openldap["svc-db-openldap ๐Ÿณ๐Ÿ"] + dep_svc_net_tor["svc-net-tor ๐Ÿณ๐Ÿ"] + dep_web_app_dashboard["web-app-dashboard ๐Ÿณ๐Ÿ"] + dep_web_app_keycloak["web-app-keycloak ๐Ÿณ๐Ÿ"] + dep_web_app_matomo["web-app-matomo ๐Ÿณ๐Ÿ"] + dep_web_app_prometheus["web-app-prometheus ๐Ÿณ๐Ÿ"] + dep_web_app_stalwart["web-app-stalwart ๐Ÿณ๐Ÿ"] + dep_web_svc_css["web-svc-css ๐Ÿ’ป"] + dep_web_svc_logout["web-svc-logout ๐Ÿณ๐Ÿ"] + end + subgraph role [web-app-wazuh ๐Ÿณ๐Ÿ] + svc_email["email โŒ"] + svc_sso["sso"] + svc_ldap["ldap"] + svc_logout["logout"] + svc_dashboard["dashboard"] + svc_matomo["matomo"] + svc_prometheus["prometheus"] + svc_css["css"] + svc_container_backup["container_backup"] + svc_wazuh["wazuh"] + svc_certs_generator["certs_generator"] + svc_manager["manager"] + svc_indexer["indexer"] + svc_console["console"] + svc_tor["tor"] + end + dep_svc_bkp_volume_2_local -. "0..1" .-> svc_container_backup + dep_svc_db_openldap -. "0..1" .-> svc_ldap + dep_svc_net_tor -. "0..1" .-> svc_tor + dep_web_app_dashboard -. "0..1" .-> svc_dashboard + dep_web_app_keycloak -. "0..1" .-> svc_sso + dep_web_app_matomo -. "0..1" .-> svc_matomo + dep_web_app_prometheus -. "0..1" .-> svc_prometheus + dep_web_app_stalwart -- "0..0" --> svc_email + dep_web_svc_css -. "0..1" .-> svc_css + dep_web_svc_logout -. "0..1" .-> svc_logout + linkStyle 7 stroke:red; +``` + +Solid `1:1` edges are fixed relationships; dashed `0..1` edges are conditional (enabled only in matching deployments); red `0..0` edges are turned off in this role. Node markers show the role's deploy modes (๐Ÿ’ป host, ๐Ÿณ compose, ๐Ÿ swarm); โŒ marks a service that is explicitly turned off, and โš™๏ธ an Ansible role dependency declared in `meta/main.yml`. + +## Features + +- **Single-node Wazuh stack:** Manager, indexer, and dashboard at the versions pinned in [services.yml](./meta/services.yml). +- **Native OIDC login:** The dashboard signs users in against Keycloak's discovery endpoint, without an oauth2-proxy sidecar. +- **LDAP authorization:** LDAP group membership adds `backend_roles` to an already authenticated user and is never used to verify a password. +- **Three-tier RBAC:** Administrator, Security Analyst, and Read-only Auditor, declared in [rbac.yml](./meta/rbac.yml) and mapped onto OpenSearch security roles. +- **Generated credentials:** Every service account password and the manager cluster key come from [secrets.yml](./meta/secrets.yml). + +## Quick Setup + +### Development + +Clone, set up the workstation, and deploy Wazuh onto the local stack: + +```bash +git clone https://github.com/infinito-nexus/core.git +cd core +make onboard +make compose-deploy mode=reinstall apps=web-app-wazuh full_cycle=false +``` + +### Production + +Run the published image to provision the inventory and deploy Wazuh to a managed server (the mounted volume persists the inventory): + +```bash +APP=web-app-wazuh +HOST= +DOMAIN= +TLS_MODE=self_signed +SSH_PUBLIC_KEY="" + +docker run --rm -it \ + -v "$PWD/inventories:/etc/infinito.nexus/inventories" \ + -e APP="$APP" -e HOST="$HOST" -e DOMAIN="$DOMAIN" -e TLS_MODE="$TLS_MODE" -e SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" \ + ghcr.io/infinito-nexus/core/debian bash -c ' + INVENTORY=/etc/infinito.nexus/inventories/production + infinito administration inventory provision "$INVENTORY" \ + --inventory-file "$INVENTORY/devices.yml" \ + --host "$HOST" \ + --include "$APP" \ + --vars "{\"TLS_MODE\": \"$TLS_MODE\", \"DOMAIN_PRIMARY\": \"$DOMAIN\", \"users\": {\"administrator\": {\"authorized_keys\": [\"$SSH_PUBLIC_KEY\"]}}}" && + infinito administration deploy dedicated "$INVENTORY/devices.yml" \ + --password-file "$INVENTORY/.password" \ + --diff -vv' +``` + +## Auth flow + +```mermaid +sequenceDiagram + actor U as Browser (biber) + participant D as wazuh.dashboard + participant I as wazuh.indexer (OpenSearch Security) + participant K as Keycloak + participant L as LDAP (authz only) + + U->>D: GET / (no session) + D->>I: forward request + Note over I: basic_internal_auth_domain, order 0
no Authorization header, falls through + Note over I: openid_auth_domain, order 1, challenge true + I-->>D: 401, redirect to Keycloak + D-->>U: redirect to Keycloak login + U->>K: submit credentials + K-->>U: redirect back with auth code + U->>D: callback with auth code + D->>K: exchange code for tokens + K-->>D: id_token, groups claim, full.path=true + D->>I: authenticated request, bearer token + Note over I: subject_key=preferred_username
roles_key=groups maps to backend_roles + I->>L: enrich backend_roles via ldap_authz_domain
CN lookup, skips admin and kibanaserver + L-->>I: additional backend_roles, if matched + Note over I: roles_mapping.yml maps backend_roles
to all_access, security_analyst, or readonly_auditor + I-->>D: authorized response + D-->>U: authenticated dashboard, RBAC-scoped +``` + +The internal `admin` and `kibanaserver` service accounts authenticate with HTTP basic auth against `internal_users.yml` and bypass OIDC and LDAP. +With `sso` disabled, these two accounts are the only way to sign in. + +## RBAC + +| Role | OpenSearch role | `backend_roles` (OIDC) | `backend_roles` (LDAP) | +|---|---|---|---| +| Administrator | `all_access` | `/roles/web-app-wazuh/administrator` | `web-app-wazuh-administrator` | +| Security Analyst | `security_analyst` | `/roles/web-app-wazuh/security-analyst` | `web-app-wazuh-security-analyst` | +| Read-only Auditor | `readonly_auditor`, `kibana_read_only` | `/roles/web-app-wazuh/readonly-auditor` | `web-app-wazuh-readonly-auditor` | + +- Grant a role by adding the user to the matching Keycloak group or LDAP group. +- A group change applies on the user's next request, because the indexer's auth cache is disabled. +- Only Administrators reach the OpenSearch Dashboards Security app. +- Every role lands on `/app/home` after login. + +## Developer Notes + +Variant matrix: [variants.yml](./meta/variants.yml). Service flags and image pins: [services.yml](./meta/services.yml). Credentials: [secrets.yml](./meta/secrets.yml). RBAC roles: [rbac.yml](./meta/rbac.yml). + +## Further Resources + +- [Wazuh Official Website](https://wazuh.com/) +- [Wazuh Documentation](https://documentation.wazuh.com/current/) +- [wazuh-docker GitHub](https://github.com/wazuh/wazuh-docker) + +## Credits + +Implemented by **[Prageeth Panicker](https://github.com/pragepani)**. +Part of the [Infinito.Nexus Project](https://s.infinito.nexus/code) and maintained by [Kevin Veen-Birkenbach](https://www.veen.world). +Licensed under the [Infinito.Nexus Community License (Non-Commercial)](https://s.infinito.nexus/license). diff --git a/roles/web-app-wazuh/files/playwright/.nocheck b/roles/web-app-wazuh/files/playwright/.nocheck new file mode 100644 index 0000000000..9ceebc5e82 --- /dev/null +++ b/roles/web-app-wazuh/files/playwright/.nocheck @@ -0,0 +1 @@ +# nocheck: role-files-layout - Playwright discovers this suite by path; the runner copies the tree as it stands and the specs import their helpers relatively. Sorting by extension would break discovery. diff --git a/roles/web-app-wazuh/files/playwright/_shared.js b/roles/web-app-wazuh/files/playwright/_shared.js new file mode 100644 index 0000000000..13ff04eb16 --- /dev/null +++ b/roles/web-app-wazuh/files/playwright/_shared.js @@ -0,0 +1,126 @@ +const { expect } = require("@playwright/test"); +const { decodeDotenvQuotedValue, normalizeBaseUrl, gotoOnion } = require("./personas"); +const { resolveTimeout } = require("./timeouts"); +const keycloakAdmin = require("./personas/utils/keycloak"); + +const env = { + appBaseUrl: normalizeBaseUrl(process.env.APP_BASE_URL || ""), + keycloakBaseUrl: normalizeBaseUrl(process.env.KEYCLOAK_BASE_URL || ""), + realmName: decodeDotenvQuotedValue(process.env.KEYCLOAK_REALM_NAME), + superAdminUsername: decodeDotenvQuotedValue(process.env.SUPER_ADMIN_USERNAME), + superAdminPassword: decodeDotenvQuotedValue(process.env.SUPER_ADMIN_PASSWORD), + adminUsername: decodeDotenvQuotedValue(process.env.ADMIN_USERNAME), + adminPassword: decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD), + biberUsername: decodeDotenvQuotedValue(process.env.BIBER_USERNAME), + biberPassword: decodeDotenvQuotedValue(process.env.BIBER_PASSWORD), + keycloakAdminRealm: decodeDotenvQuotedValue(process.env.KEYCLOAK_ADMIN_REALM), + keycloakAdminCliClientId: decodeDotenvQuotedValue(process.env.KEYCLOAK_ADMIN_CLI_CLIENT_ID), + rbacGroupPathPrefix: decodeDotenvQuotedValue(process.env.RBAC_GROUP_PATH_PREFIX), +}; + +function attachDiagnostics(page) { + const consoleErrors = []; + const pageErrors = []; + const cspRelated = []; + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + if (/content security policy|csp/i.test(message.text())) { + cspRelated.push({ source: "console", text: message.text() }); + } + }); + page.on("pageerror", (error) => { + const text = String(error); + pageErrors.push(text); + if (/content security policy|csp/i.test(text)) { + cspRelated.push({ source: "pageerror", text }); + } + }); + return { consoleErrors, pageErrors, cspRelated }; +} + +async function wazuhLoginViaOidc(page, appBaseUrl, username, password) { + await gotoOnion(page, `${appBaseUrl}/`, { waitUntil: "domcontentloaded" }); + + if (!page.url().includes("openid-connect/auth")) { + const strictLink = page + .getByRole("link", { name: /^\s*(log\s*in|sign\s*in|login|sso)\s*$/i }) + .or(page.getByRole("button", { name: /^\s*(log\s*in|sign\s*in|login|sso)\s*$/i })) + .first(); + const looseLink = page + .getByRole("link", { name: /log\s*in|sign\s*in|sso/i }) + .or(page.getByRole("button", { name: /log\s*in|sign\s*in|sso/i })) + .first(); + await keycloakAdmin.clickOidcLoginLink(page, strictLink, looseLink); + } + + if (page.url().includes("openid-connect/auth")) { + await keycloakAdmin.performKeycloakLoginForm(page, username, password); + } + + await expect + .poll(() => page.url(), { + timeout: resolveTimeout(60_000), + message: `Expected redirect back to ${appBaseUrl} after OIDC login`, + }) + .toContain(new URL(appBaseUrl).host); + await expect + .poll(async () => (await page.context().cookies()).length, { + timeout: resolveTimeout(30_000), + message: + `Expected a real session cookie after OIDC login to ${appBaseUrl} ` + + `(got 0 - the URL matched but no session was actually established)`, + }) + .toBeGreaterThan(0); +} + +async function withBiberInGroup(browser, groupPath, fn) { + const adminOpts = { adminRealm: env.keycloakAdminRealm, adminClientId: env.keycloakAdminCliClientId }; + const adminCtx = await browser.newContext({ ignoreHTTPSErrors: true }); + let biberAdded = false; + let bodyFailed = false; + try { + biberAdded = await keycloakAdmin.keycloakAdminAddUserToGroup( + adminCtx.request, + env.keycloakBaseUrl, + env.realmName, + groupPath, + env.biberUsername, + env.superAdminUsername, + env.superAdminPassword, + adminOpts, + ); + return await fn(); + } catch (err) { + bodyFailed = true; + throw err; + } finally { + try { + if (biberAdded) { + await keycloakAdmin + .keycloakRemoveUserFromGroupViaRest( + adminCtx.request, + env.keycloakBaseUrl, + env.realmName, + env.superAdminUsername, + env.superAdminPassword, + groupPath, + env.biberUsername, + adminOpts, + ) + .catch((err) => { + if (!bodyFailed) throw err; + console.warn(`Cleanup removal of biber from ${groupPath} failed: ${err}`); + }); + } + } finally { + await adminCtx.close().catch(() => {}); + } + } +} + +module.exports = { + env, + attachDiagnostics, + wazuhLoginViaOidc, + withBiberInGroup, +}; diff --git a/roles/web-app-wazuh/files/playwright/playwright.spec.js b/roles/web-app-wazuh/files/playwright/playwright.spec.js new file mode 100644 index 0000000000..b9fdabeaf1 --- /dev/null +++ b/roles/web-app-wazuh/files/playwright/playwright.spec.js @@ -0,0 +1,10 @@ +const { test } = require("@playwright/test"); + +const testBaseline = require("./test-baseline"); +const testRbacRoles = require("./test-rbac-roles"); +require("./test-csp"); + +test.describe.serial("wazuh: biber Keycloak group membership (single-owner)", () => { + testBaseline.registerBiberBaseline(); + testRbacRoles.register(); +}); diff --git a/roles/web-app-wazuh/files/playwright/test-baseline.js b/roles/web-app-wazuh/files/playwright/test-baseline.js new file mode 100644 index 0000000000..d825ffe315 --- /dev/null +++ b/roles/web-app-wazuh/files/playwright/test-baseline.js @@ -0,0 +1,39 @@ +const { test, expect } = require("@playwright/test"); +const { runGuestFlow, runBiberFlow, runAdminFlow, gotoOnion } = require("./personas"); +const { skipUnlessServiceEnabled } = require("./service-gating"); +const { resolveTimeout } = require("./timeouts"); +const shared = require("./_shared"); + +test.use({ ignoreHTTPSErrors: true }); + +test("guest: public-landing โ†’ auth chain โ†’ never authenticated", async ({ page }) => { + await runGuestFlow(page); +}); + +function registerBiberBaseline() { + test("biber: dashboard โ†’ keycloak โ†’ view alerts โ†’ logout", async ({ page, browser }) => { + skipUnlessServiceEnabled("sso"); + await shared.withBiberInGroup(browser, `${shared.env.rbacGroupPathPrefix}readonly-auditor`, () => + runBiberFlow(page, { + biberInteraction: async (p) => { + await expect(p.locator("body")).toContainText(/home|manage|dev tools/i, { + timeout: resolveTimeout(30_000), + }); + }, + }), + ); + }); +} + +test("administrator: app โ†’ keycloak โ†’ admin action โ†’ logout", async ({ page }) => { + await runAdminFlow(page, { + adminInteraction: async (p) => { + await gotoOnion(p, `${shared.env.appBaseUrl}/app/security-dashboards-plugin#/roles`); + await expect(p.locator("body")).toContainText(/security|roles|users|management/i, { + timeout: resolveTimeout(30_000), + }); + }, + }); +}); + +module.exports = { registerBiberBaseline }; diff --git a/roles/web-app-wazuh/files/playwright/test-csp.js b/roles/web-app-wazuh/files/playwright/test-csp.js new file mode 100644 index 0000000000..f32b4a7eee --- /dev/null +++ b/roles/web-app-wazuh/files/playwright/test-csp.js @@ -0,0 +1,76 @@ +const { test, expect } = require("@playwright/test"); +const { + installCspViolationObserver, + assertCspResponseHeader, + assertCspMetaParity, + expectNoCspViolations, + gotoOnion, +} = require("./personas"); +const { skipUnlessServiceEnabled } = require("./service-gating"); +const { resolveTimeout } = require("./timeouts"); +const shared = require("./_shared"); + +test.use({ ignoreHTTPSErrors: true }); + +const CONSOLE_EDITOR_SELECTORS = [ + ".ace_text-input", + ".monaco-editor textarea", + "[data-test-subj='console-textarea'] textarea", + ".ace_editor textarea", +]; + +async function typeIntoDevToolsConsole(page) { + const response = await gotoOnion(page, `${shared.env.appBaseUrl}/app/dev_tools#/console`, { + waitUntil: "domcontentloaded", + }); + await page.waitForLoadState("networkidle").catch(() => {}); + if (!response || response.status() >= 400) { + throw new Error(`dev tools console answered ${response ? response.status() : "no response"}`); + } + const editor = page.locator(CONSOLE_EDITOR_SELECTORS.join(", ")).first(); + await editor.waitFor({ state: "visible", timeout: resolveTimeout(15_000) }); + await page + .getByRole("button", { name: /dismiss|close this dialog/i }) + .first() + .click({ timeout: resolveTimeout(5_000) }) + .catch(() => {}); + await editor.focus({ timeout: resolveTimeout(10_000) }); + await page.keyboard.type("GET _cluster/health", { delay: 50 }); + await page.waitForTimeout(resolveTimeout(3_000)); +} + +test("wazuh dashboard: script-src-elem unsafe-inline and worker-src blob: are declared and exercised without violation", async ({ + page, +}) => { + skipUnlessServiceEnabled("sso"); + + const diagnostics = shared.attachDiagnostics(page); + await installCspViolationObserver(page); + + await shared.wazuhLoginViaOidc( + page, + shared.env.appBaseUrl, + shared.env.adminUsername, + shared.env.adminPassword, + ); + + const response = await gotoOnion(page, `${shared.env.appBaseUrl}/app/home`); + expect(response, "Expected Wazuh dashboard home response").toBeTruthy(); + expect( + response.status(), + "Expected Wazuh dashboard home response to be successful", + ).toBeLessThan(400); + + const directives = assertCspResponseHeader(response, "wazuh dashboard home"); + await assertCspMetaParity(page, directives, "wazuh dashboard home"); + + expect( + directives["script-src-elem"] || [], + "Expected script-src-elem to declare 'unsafe-inline'", + ).toContain("'unsafe-inline'"); + expect(directives["worker-src"] || [], "Expected worker-src to grant blob:").toContain("blob:"); + + await typeIntoDevToolsConsole(page); + + await expectNoCspViolations(page, diagnostics, "wazuh dashboard (home + dev tools console)"); +}); diff --git a/roles/web-app-wazuh/files/playwright/test-rbac-roles.js b/roles/web-app-wazuh/files/playwright/test-rbac-roles.js new file mode 100644 index 0000000000..96477e3650 --- /dev/null +++ b/roles/web-app-wazuh/files/playwright/test-rbac-roles.js @@ -0,0 +1,84 @@ +const { test, expect } = require("@playwright/test"); +const { installCspViolationObserver, gotoOnion } = require("./personas"); +const { skipUnlessServiceEnabled } = require("./service-gating"); +const { resolveTimeout } = require("./timeouts"); +const shared = require("./_shared"); + +const RBAC_TIERS = [ + { role: "administrator", expectSecurityUi: true }, + { role: "security-analyst", expectSecurityUi: false }, + { role: "readonly-auditor", expectSecurityUi: false }, +]; + +async function seenBy(page) { + const body = await page + .locator("body") + .innerText({ timeout: resolveTimeout(5_000) }) + .catch((err) => ``); + return `page: ${page.url()}, body: ${body.slice(0, 1500)}`; +} + +exports.register = function () { + for (const tier of RBAC_TIERS) { + test(`rbac: membership in ${tier.role} group grants the expected Wazuh UI surface`, async ({ + browser, + }) => { + skipUnlessServiceEnabled("sso"); + await shared.withBiberInGroup(browser, `${shared.env.rbacGroupPathPrefix}${tier.role}`, async () => { + const biberCtx = await browser.newContext({ ignoreHTTPSErrors: true }); + try { + const page = await biberCtx.newPage(); + await installCspViolationObserver(page); + await shared.wazuhLoginViaOidc( + page, + shared.env.appBaseUrl, + shared.env.biberUsername, + shared.env.biberPassword, + ); + + const nav = await gotoOnion(page, `${shared.env.appBaseUrl}/app/security-dashboards-plugin#/roles`, { + waitUntil: "domcontentloaded", + }); + expect( + nav ? nav.status() : 0, + `${tier.role} navigation to the Security management UI failed (${await seenBy(page)})`, + ).toBeLessThan(400); + const roleTable = page.locator("[data-test-subj='role-list']"); + + if (tier.expectSecurityUi) { + await roleTable + .first() + .waitFor({ state: "visible", timeout: resolveTimeout(30_000) }) + .catch(async (err) => { + throw new Error( + `${tier.role} MUST reach the Security management UI (${await seenBy(page)}): ${err.message}`, + ); + }); + } else { + await page.waitForTimeout(resolveTimeout(10_000)); + const seen = await seenBy(page); + // Exception: the explicit denial text is not verified yet; these two checks stand in for it until CI shows what a denied user sees. + expect( + page.url().startsWith(shared.env.appBaseUrl), + `${tier.role} MUST be denied inside the Wazuh app, not redirected away (${seen})`, + ).toBe(true); + expect( + (await page.locator("body").innerText({ timeout: resolveTimeout(5_000) })).trim(), + `${tier.role} MUST see a denial or empty-permission page, not a blank page (${seen})`, + ).not.toBe(""); + await expect( + roleTable, + `${tier.role} MUST NOT reach the Security management UI (role table rendered, ${seen})`, + ).toHaveCount(0); + await expect( + page.locator("[data-test-subj='create-role']"), + `${tier.role} MUST NOT reach the Security management UI (create-role button rendered, ${seen})`, + ).toHaveCount(0); + } + } finally { + await biberCtx.close().catch(() => {}); + } + }); + }); + } +}; diff --git a/roles/web-app-wazuh/files/roles.yml b/roles/web-app-wazuh/files/roles.yml new file mode 100644 index 0000000000..6dee71147d --- /dev/null +++ b/roles/web-app-wazuh/files/roles.yml @@ -0,0 +1,41 @@ +--- +_meta: + type: "roles" + config_version: 2 + +security_analyst: + reserved: false + description: "Security Analyst: read alerts/logs/dashboards, no security-config or RBAC-management access." + cluster_permissions: + - "cluster_composite_ops_ro" + index_permissions: + - index_patterns: + - "wazuh-alerts-*" + - "wazuh-archives-*" + - "wazuh-monitoring-*" + - "wazuh-statistics-*" + allowed_actions: + - "read" + - "indices:admin/mappings/get" + - "indices:admin/get" + tenant_permissions: + - tenant_patterns: + - "global_tenant" + allowed_actions: + - "kibana_all_read" + +readonly_auditor: + reserved: false + description: "Read-only Auditor: read-only access to dashboards and alerts across all Wazuh indices, no agent/rule/configuration actions." + cluster_permissions: + - "cluster_composite_ops_ro" + index_permissions: + - index_patterns: + - "wazuh-*" + allowed_actions: + - "read" + tenant_permissions: + - tenant_patterns: + - "global_tenant" + allowed_actions: + - "kibana_all_read" diff --git a/roles/web-app-wazuh/files/shell/certs-entrypoint.sh b/roles/web-app-wazuh/files/shell/certs-entrypoint.sh new file mode 100755 index 0000000000..0a2ff422df --- /dev/null +++ b/roles/web-app-wazuh/files/shell/certs-entrypoint.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +unset CURL_CA_BUNDLE SSL_CERT_FILE REQUESTS_CA_BUNDLE NODE_EXTRA_CA_CERTS + +: "${CERT_TOOL_VERSION:?CERT_TOOL_VERSION must name the wazuh-certs-tool release}" +: "${WAZUH_MANAGER_NODE:?WAZUH_MANAGER_NODE must name the manager node in /config/certs.yml}" + +config_src="/config/certs.yml" +out_dir="/certificates" +tool_url="https://packages.wazuh.com/${CERT_TOOL_VERSION}/wazuh-certs-tool.sh" + +work_dir="$(mktemp -d)" +trap 'rm -rf "${work_dir}"' EXIT + +if ! curl --connect-timeout 10 --max-time 120 --fail --silent --show-error --location \ + --retry 5 --retry-delay 5 --retry-all-errors \ + --output "${work_dir}/wazuh-certs-tool.sh" "${tool_url}"; then + echo "ERROR: downloading ${tool_url} failed; no certificates were generated" >&2 + exit 1 +fi + +cp "${config_src}" "${work_dir}/config.yml" +bash "${work_dir}/wazuh-certs-tool.sh" --all + +cp "${work_dir}"/wazuh-certificates/* "${out_dir}/" +cp "${out_dir}/root-ca.pem" "${out_dir}/root-ca-manager.pem" +cp "${out_dir}/root-ca.key" "${out_dir}/root-ca-manager.key" + +chmod 0400 "${out_dir}"/* +chown 1000:1000 "${out_dir}"/* +chown 999:999 \ + "${out_dir}/root-ca-manager.pem" \ + "${out_dir}/root-ca-manager.key" \ + "${out_dir}/${WAZUH_MANAGER_NODE}.pem" \ + "${out_dir}/${WAZUH_MANAGER_NODE}-key.pem" +chmod 0500 "${out_dir}" + +echo "Wazuh certificates written to ${out_dir}" diff --git a/roles/web-app-wazuh/files/shell/indexer-entrypoint.sh b/roles/web-app-wazuh/files/shell/indexer-entrypoint.sh new file mode 100755 index 0000000000..35b2aec57d --- /dev/null +++ b/roles/web-app-wazuh/files/shell/indexer-entrypoint.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +if [ -n "${CA_TRUST_CERT:-}" ] && [ -r "${CA_TRUST_CERT}" ]; then + CACERTS="/usr/share/wazuh-indexer/jdk/lib/security/cacerts" + ALIAS="${CA_TRUST_NAME:?CA_TRUST_NAME must accompany CA_TRUST_CERT}" + KEYTOOL="/usr/share/wazuh-indexer/jdk/bin/keytool" + if ! "$KEYTOOL" -list -keystore "$CACERTS" -storepass changeit -alias "$ALIAS" >/dev/null 2>&1; then + "$KEYTOOL" -importcert -noprompt -alias "$ALIAS" -file "$CA_TRUST_CERT" -keystore "$CACERTS" -storepass changeit + fi +fi + +exec /entrypoint.sh "$@" diff --git a/roles/web-app-wazuh/meta/csp.yml b/roles/web-app-wazuh/meta/csp.yml new file mode 100644 index 0000000000..c85fbafdde --- /dev/null +++ b/roles/web-app-wazuh/meta/csp.yml @@ -0,0 +1,8 @@ +--- +flags: + script-src-elem: + unsafe-inline: true # nocheck: csp-unsafe Reason: OpenSearch Dashboards (Kibana-derived) bootstraps via inline