Merge pull request #414 from pragepani/feature/web-app-wazuh/add-role

feat(web-app-wazuh): add Wazuh SIEM role with OIDC/LDAP RBAC
This commit is contained in:
kevinveenbirkenbach authored and GitHub committed 2026-10-19 14:35:24 +02:00
commit d0698daa5e
46 files changed
+2452 -4

No files matched your search

+1
View File
@@ -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/) |
+2 -2
View File
@@ -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)
+5
View File
@@ -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) }}"
+70
View File
@@ -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")
@@ -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,
};
@@ -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",
),
];
}
+174
View File
@@ -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=<your-server>
DOMAIN=<your-domain>
TLS_MODE=self_signed
SSH_PUBLIC_KEY="<your-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<br/>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<br/>roles_key=groups maps to backend_roles
I->>L: enrich backend_roles via ldap_authz_domain<br/>CN lookup, skips admin and kibanaserver
L-->>I: additional backend_roles, if matched
Note over I: roles_mapping.yml maps backend_roles<br/>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).
@@ -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.
@@ -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,
};
@@ -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();
});
@@ -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 };
@@ -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)");
});
@@ -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) => `<body text unavailable: ${err.message}>`);
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(() => {});
}
});
});
}
};
+41
View File
@@ -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"
+39
View File
@@ -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}"
+11
View File
@@ -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 "$@"
+8
View File
@@ -0,0 +1,8 @@
---
flags:
script-src-elem:
unsafe-inline: true # nocheck: csp-unsafe Reason: OpenSearch Dashboards (Kibana-derived) bootstraps via inline <script> tags injected by its plugin loader; blocking inline scripts breaks the dashboard shell before it can render.
unsafe-eval: true # nocheck: csp-unsafe Reason: OpenSearch Dashboards' bundled AngularJS runtime and Vega/Timelion visualization expressions evaluate code dynamically at runtime; blocking eval breaks those code paths, same as every other Kibana/OpenSearch-Dashboards-family role in this repo (e.g. web-app-checkmk).
whitelist:
worker-src:
- "blob:"
+4
View File
@@ -0,0 +1,4 @@
---
canonical:
- wazuh.{{ DOMAIN_PRIMARY }}
aliases: []
+5
View File
@@ -0,0 +1,5 @@
---
logo:
class: fa-solid fa-magnifying-glass-chart
homepage: https://wazuh.com/
video: https://www.youtube.com/watch?v=peTSzcAueEc
+36
View File
@@ -0,0 +1,36 @@
---
galaxy_info:
author: Prageeth Panicker
description: Open-source SIEM and XDR platform for monitoring, log analysis, intrusion/vulnerability detection, and compliance
license: Infinito.Nexus Community License (Non-Commercial)
company: |
Kevin Veen-Birkenbach
https://www.veen.world
galaxy_tags:
- wazuh
- siem
- xdr
- security
- monitoring
- opensearch
- self-hosted
- docker
issue_tracker_url: https://s.infinito.nexus/issues
min_ansible_version: "2.9"
platforms:
- name: ArchLinux
versions:
- all
- name: Debian
versions:
- all
- name: EL
versions:
- all
- name: Fedora
versions:
- all
- name: Ubuntu
versions:
- all
dependencies: []
+3
View File
@@ -0,0 +1,3 @@
---
local:
subnet: 192.168.81.0/24
+8
View File
@@ -0,0 +1,8 @@
---
roles:
administrator:
description: "Full administrative access: agents, rules, and security configuration."
security-analyst:
description: "Reads alerts, logs, and dashboards. No security-configuration or RBAC-management access."
readonly-auditor:
description: "Read-only access to dashboards and alerts. No agent, rule, or configuration actions."
+24
View File
@@ -0,0 +1,24 @@
---
credentials:
indexer_admin_password:
description: "Password for the wazuh-indexer's internal 'admin' service account (OpenSearch security plugin). Consumed by manager/dashboard as INDEXER_PASSWORD,
by Ansible health checks and securityadmin.sh, and hashed into internal_users.yml."
algorithm: alphanumeric
validation: "^[A-Za-z0-9]{32,}$"
length: 40
dashboard_service_password:
description: "Password for the wazuh-indexer's internal 'kibanaserver' service account. Consumed by wazuh.dashboard as DASHBOARD_PASSWORD and hashed into internal_users.yml."
algorithm: alphanumeric
validation: "^[A-Za-z0-9]{32,}$"
length: 40
wazuh_wui_password:
description: "Password for the 'wazuh-wui' Wazuh API account. Consumed by wazuh.manager (API_PASSWORD, creates the account) and wazuh.dashboard (WAZUH API connection
in wazuh.yml)."
algorithm: alphanumeric
validation: "^[A-Za-z0-9]{32,}$"
length: 40
manager_cluster_key:
description: "wazuh-manager cluster key (ossec.conf <cluster><key>). Clustering is disabled in this single-node deployment, but the key MUST NOT ship as the well-known
upstream demo value in case clustering is enabled later."
algorithm: random_hex_16
validation: "^[a-f0-9]{32}$"
+7
View File
@@ -0,0 +1,7 @@
---
status_codes:
default:
- 200
- 302
- 401
- 403
+138
View File
@@ -0,0 +1,138 @@
---
# nocheck: email, dynamic-flag, playwright-service-flag
email:
enabled: false
shared: false
sso:
bond: 1
enabled: "{{ 'web-app-keycloak' in group_names }}"
shared: "{{ 'web-app-keycloak' in group_names }}"
flavor: oidc
# nocheck: playwright-service-flag authorization backend of the indexer's OpenSearch security plugin, no LDAP UI surface to probe.
ldap:
bond: 1
enabled: "{{ 'svc-db-openldap' in group_names }}"
shared: "{{ 'svc-db-openldap' in group_names }}"
logout:
bond: 1
enabled: "{{ 'web-svc-logout' in group_names }}"
shared: "{{ 'web-svc-logout' in group_names }}"
# nocheck: playwright-service-flag dashboard-tile reachability is owned by web-app-dashboard/files/playwright/playwright.spec.js, not consumer roles.
dashboard:
bond: 1
enabled: "{{ 'web-app-dashboard' in group_names }}"
shared: "{{ 'web-app-dashboard' in group_names }}"
matomo:
bond: 1
enabled: "{{ 'web-app-matomo' in group_names }}"
shared: "{{ 'web-app-matomo' in group_names }}"
# nocheck: playwright-service-flag Wazuh exposes no native /metrics endpoint; nothing for a consumer-role spec to assert here.
prometheus:
bond: 1
enabled: "{{ 'web-app-prometheus' in group_names }}"
shared: "{{ 'web-app-prometheus' in group_names }}"
# nocheck: playwright-service-flag css consumer, no Playwright surface (provider: web-svc-css)
css:
bond: 1
enabled: "{{ 'web-svc-css' in group_names }}"
shared: "{{ 'web-svc-css' in group_names }}"
# nocheck: playwright-service-flag, dynamic-flag infrastructure backup consumer
container_backup: # nocheck: bond-baseline backup host joins outside the variant topology
bond: 1
enabled: "{{ 'svc-bkp-volume-2-local' in group_names }}"
shared: true
wazuh:
modes:
compose:
enabled: true
swarm:
enabled: true
# nocheck: default-placement-manager certs_generator writes the TLS material into a host bind directory that manager, indexer and dashboard mount, so all of them have to run on that node.
placement: manager
min_storage: 1GB
cpus: "0.2"
mem_reservation: 512m
mem_limit: 1g
pids_limit: 256
run_after:
- web-app-keycloak
- svc-db-openldap
lifecycle: beta
certs_generator:
image: wazuh/wazuh-certs-generator
version: "0.0.4"
name: wazuh-certs-generator
cpus: "0.2"
mem_reservation: 64m
mem_limit: 256m
pids_limit: 128
manager:
image: wazuh/wazuh-manager
version: "4.14.6"
name: wazuh.manager
# nocheck: min-storage-headroom Reason: budgets room for wazuh-manager's own operational data (var-ossec logs, alerts, agent queue/state) to grow well beyond the base image over the service's lifetime, not just the image itself.
min_storage: 10GB
cpus: "1.0"
mem_reservation: 512m
mem_limit: 2g
pids_limit: 1024
nofile: 655360
backup:
no_stop_required: false
ports:
internal:
agent_events: 1514
agent_registration: 1515
api: 55000
indexer:
healthcheck:
test:
- "CMD-SHELL"
- "curl -s -o /dev/null -w '%{http_code}' -k https://localhost:{{ lookup('config', 'web-app-wazuh', 'services.indexer.ports.internal.http') }}/_cluster/health
| grep -Eq '^(200|401)$$'"
# nocheck: healthcheck-timing-override manager and dashboard depend_on indexer with condition: service_healthy (see compose.yml.j2); the curl flavor's 2m cadence would leave both blocked for minutes past the point the indexer is actually ready.
interval: 30s
# nocheck: healthcheck-timing-override the -k https probe against the security plugin can take longer than the 5s default while the TLS handshake completes during startup.
timeout: 10s
retries: 10
# nocheck: healthcheck-timing-override covers the indexer's real boot without waiting out the curl flavor's 10m default, which would otherwise stall manager/dashboard's startup order far longer than the indexer actually needs.
start_period: 90s
image: wazuh/wazuh-indexer
version: "4.14.6"
name: wazuh.indexer
# nocheck: min-storage-headroom Reason: budgets room for the OpenSearch index data (security events/alerts Wazuh continuously ingests) to grow well beyond the base image over the service's lifetime, not just the image itself.
min_storage: 20GB
cpus: "2.0"
mem_reservation: 1g
mem_limit: 2g
pids_limit: 2048
nofile: 65536
backup:
no_stop_required: false
ports:
internal:
http: 9200
local:
http: 8097
console:
image: wazuh/wazuh-dashboard
version: "4.14.6"
name: wazuh.dashboard
# nocheck: min-storage-headroom Reason: budgets room for the dashboard's own runtime state (OpenSearch Dashboards bundle/build cache rebuilt on first boot, plus exported reports and saved-object data) to grow beyond the base image, not just the image itself.
min_storage: 2GB
cpus: "1.0"
mem_reservation: 512m
mem_limit: 1g
pids_limit: 512
ports:
internal:
http: 5601
local:
http: 8096
# nocheck: playwright-service-flag
# nocheck: variants-coverage — sso-variant-tor requires the onion on in every variant, so the false polarity has no variant left to live in
tor:
bond: 0.5
enabled: "{{ 'svc-net-tor' in group_names }}"
shared: true # nocheck: dynamic-flag
+10
View File
@@ -0,0 +1,10 @@
---
admin: {}
web-app-wazuh-dashboard:
username: kibanaserver
accounts: []
description: Service account wazuh.dashboard uses for its backend calls to wazuh.indexer
web-app-wazuh-api:
username: wazuh-wui
accounts: []
description: Wazuh API account the dashboard connects with; created by wazuh.manager
+55
View File
@@ -0,0 +1,55 @@
---
- services:
container_backup:
enabled: true
sso:
enabled: true
shared: true
ldap:
enabled: true
shared: true
logout:
enabled: true
shared: true
dashboard:
enabled: true
shared: true
matomo:
enabled: true
shared: true
prometheus:
enabled: true
shared: true
css:
enabled: true
shared: true
tor:
enabled: true
- services:
container_backup:
enabled: false
shared: false # nocheck: variants-static-override backup shared stays literally true
sso:
enabled: false
shared: false
ldap:
enabled: false
shared: false
logout:
enabled: false
shared: false
dashboard:
enabled: false
shared: false
matomo:
enabled: false
shared: false
prometheus:
enabled: false
shared: false
css:
enabled: false
shared: false
tor:
enabled: true
+257
View File
@@ -0,0 +1,257 @@
---
manager_api_configuration:
type: volume
name: wazuh_manager_api_configuration
mounts:
- service: manager
target: /var/ossec/api/configuration
manager_etc:
type: volume
name: wazuh_manager_etc
mounts:
- service: manager
target: /var/ossec/etc
manager_logs:
type: volume
name: wazuh_manager_logs
mounts:
- service: manager
target: /var/ossec/logs
manager_queue:
type: volume
name: wazuh_manager_queue
mounts:
- service: manager
target: /var/ossec/queue
manager_vd:
type: volume
name: wazuh_manager_vd
backup: false
mounts:
- service: manager
target: /var/ossec/queue/vd
manager_var_multigroups:
type: volume
name: wazuh_manager_var_multigroups
backup: false
mounts:
- service: manager
target: /var/ossec/var/multigroups
manager_integrations:
type: volume
name: wazuh_manager_integrations
mounts:
- service: manager
target: /var/ossec/integrations
manager_active_response:
type: volume
name: wazuh_manager_active_response
mounts:
- service: manager
target: /var/ossec/active-response/bin
manager_agentless:
type: volume
name: wazuh_manager_agentless
mounts:
- service: manager
target: /var/ossec/agentless
manager_wodles:
type: volume
name: wazuh_manager_wodles
mounts:
- service: manager
target: /var/ossec/wodles
manager_filebeat_etc:
type: volume
name: wazuh_manager_filebeat_etc
mounts:
- service: manager
target: /etc/filebeat
manager_filebeat_var:
type: volume
name: wazuh_manager_filebeat_var
mounts:
- service: manager
target: /var/lib/filebeat
indexer_data:
type: volume
name: wazuh_indexer_data
mounts:
- service: indexer
target: /var/lib/wazuh-indexer
dashboard_config:
type: volume
name: wazuh_dashboard_config
mounts:
- service: dashboard
target: /usr/share/wazuh-dashboard/data/wazuh/config
dashboard_custom:
type: volume
name: wazuh_dashboard_custom
mounts:
- service: dashboard
target: /usr/share/wazuh-dashboard/plugins/wazuh/public/assets/custom
certs_generator_config:
type: config
source: "{{ [ lookup('container', 'web-app-wazuh', 'directories.instance'), 'certs.yml' ] | path_join }}"
mode: "0444"
mounts:
- service: certs_generator
target: /config/certs.yml
certs_generator_entrypoint:
type: config
source: "{{ [ lookup('container', 'web-app-wazuh', 'directories.instance'), 'certs-entrypoint.sh' ] | path_join }}"
mode: "0555"
mounts:
- service: certs_generator
target: &entrypoint_target /infinito-entrypoint.sh
indexer_entrypoint:
type: config
source: "{{ [ lookup('container', 'web-app-wazuh', 'directories.instance'), 'indexer-entrypoint.sh' ] | path_join }}"
mode: "0555"
mounts:
- service: indexer
target: *entrypoint_target
wazuh_manager_conf:
type: config
source: "{{ [ lookup('container', 'web-app-wazuh', 'directories.instance'), 'wazuh_manager.conf' ] | path_join }}"
mode: "0444"
mounts:
- service: manager
target: /wazuh-config-mount/etc/ossec.conf
wazuh_indexer_yml:
type: config
source: "{{ [ lookup('container', 'web-app-wazuh', 'directories.instance'), 'wazuh.indexer.yml' ] | path_join }}"
mode: "0444"
mounts:
- service: indexer
target: /usr/share/wazuh-indexer/config/opensearch.yml
internal_users_yml:
type: config
source: "{{ [ lookup('container', 'web-app-wazuh', 'directories.instance'), 'internal_users.yml' ] | path_join }}"
mode: "0444"
mounts:
- service: indexer
target: /usr/share/wazuh-indexer/config/opensearch-security/internal_users.yml
config_yml:
type: config
source: "{{ [ lookup('container', 'web-app-wazuh', 'directories.instance'), 'config.yml' ] | path_join }}"
mode: "0444"
mounts:
- service: indexer
target: /usr/share/wazuh-indexer/config/opensearch-security/config.yml
indexer_roles_yml:
type: config
source: "{{ [ lookup('container', 'web-app-wazuh', 'directories.instance'), 'roles.yml' ] | path_join }}"
mode: "0444"
mounts:
- service: indexer
target: /usr/share/wazuh-indexer/config/opensearch-security/roles.yml
roles_mapping_yml:
type: config
source: "{{ [ lookup('container', 'web-app-wazuh', 'directories.instance'), 'roles_mapping.yml' ] | path_join }}"
mode: "0444"
mounts:
- service: indexer
target: /usr/share/wazuh-indexer/config/opensearch-security/roles_mapping.yml
opensearch_dashboards_yml:
type: secret
source: "{{ [ lookup('container', 'web-app-wazuh', 'directories.instance'), 'opensearch_dashboards.yml' ] | path_join }}"
mode: "0444"
mounts:
- service: dashboard
target: /usr/share/wazuh-dashboard/config/opensearch_dashboards.yml
dashboard_wazuh_yml:
type: config
source: "{{ [ lookup('container', 'web-app-wazuh', 'directories.instance'), 'wazuh.yml' ] | path_join }}"
mode: "0444"
mounts:
- service: dashboard
target: /usr/share/wazuh-dashboard/data/wazuh/config/wazuh.yml
wazuh_certs_dir:
type: bind
source: "{{ [ lookup('container', 'web-app-wazuh', 'directories.instance'), 'certs' ] | path_join }}"
swarm_safe: false
mounts:
- service: certs_generator
target: /certificates/
# nocheck: meta-volume-file-bind-should-be-config Reason: generated at deploy time by certs_generator in this same stack; placement: manager guarantees the bind source exists on the scheduled node.
root_ca_manager_pem:
type: bind
source: "{{ [ lookup('container', 'web-app-wazuh', 'directories.instance'), 'certs', 'root-ca-manager.pem' ] | path_join }}"
read_only: true
swarm_safe: false
mounts:
- service: manager
target: /etc/ssl/root-ca.pem
# nocheck: meta-volume-file-bind-should-be-config Reason: generated at deploy time by certs_generator in this same stack; placement: manager guarantees the bind source exists on the scheduled node.
manager_filebeat_pem:
type: bind
source: "{{ [ lookup('container', 'web-app-wazuh', 'directories.instance'), 'certs', lookup('config', 'web-app-wazuh', 'services.manager.name') ~ '.pem' ] | path_join
}}"
read_only: true
swarm_safe: false
mounts:
- service: manager
target: /etc/ssl/filebeat.pem
# nocheck: meta-volume-file-bind-should-be-config Reason: generated at deploy time by certs_generator in this same stack; placement: manager guarantees the bind source exists on the scheduled node.
manager_filebeat_key_pem:
type: bind
source: "{{ [ lookup('container', 'web-app-wazuh', 'directories.instance'), 'certs', lookup('config', 'web-app-wazuh', 'services.manager.name') ~ '-key.pem' ] |
path_join }}"
read_only: true
swarm_safe: false
mounts:
- service: manager
target: /etc/ssl/filebeat.key
# nocheck: meta-volume-file-bind-should-be-config Reason: generated at deploy time by certs_generator in this same stack; placement: manager guarantees the bind source exists on the scheduled node.
root_ca_pem:
type: bind
source: "{{ [ lookup('container', 'web-app-wazuh', 'directories.instance'), 'certs', 'root-ca.pem' ] | path_join }}"
read_only: true
swarm_safe: false
mounts:
- service: indexer
target: /usr/share/wazuh-indexer/config/certs/root-ca.pem
- service: dashboard
target: /usr/share/wazuh-dashboard/certs/root-ca.pem
# nocheck: meta-volume-file-bind-should-be-config Reason: generated at deploy time by certs_generator in this same stack; placement: manager guarantees the bind source exists on the scheduled node.
indexer_key_pem:
type: bind
source: "{{ [ lookup('container', 'web-app-wazuh', 'directories.instance'), 'certs', lookup('config', 'web-app-wazuh', 'services.indexer.name') ~ '-key.pem' ] |
path_join }}"
read_only: true
swarm_safe: false
mounts:
- service: indexer
target: /usr/share/wazuh-indexer/config/certs/wazuh.indexer.key
# nocheck: meta-volume-file-bind-should-be-config Reason: generated at deploy time by certs_generator in this same stack; placement: manager guarantees the bind source exists on the scheduled node.
indexer_cert_pem:
type: bind
source: "{{ [ lookup('container', 'web-app-wazuh', 'directories.instance'), 'certs', lookup('config', 'web-app-wazuh', 'services.indexer.name') ~ '.pem' ] | path_join
}}"
read_only: true
swarm_safe: false
mounts:
- service: indexer
target: /usr/share/wazuh-indexer/config/certs/wazuh.indexer.pem
# nocheck: meta-volume-file-bind-should-be-config Reason: generated at deploy time by certs_generator in this same stack; placement: manager guarantees the bind source exists on the scheduled node.
admin_cert_pem:
type: bind
source: "{{ [ lookup('container', 'web-app-wazuh', 'directories.instance'), 'certs', 'admin.pem' ] | path_join }}"
read_only: true
swarm_safe: false
mounts:
- service: indexer
target: /usr/share/wazuh-indexer/config/certs/admin.pem
# nocheck: meta-volume-file-bind-should-be-config Reason: generated at deploy time by certs_generator in this same stack; placement: manager guarantees the bind source exists on the scheduled node.
admin_key_pem:
type: bind
source: "{{ [ lookup('container', 'web-app-wazuh', 'directories.instance'), 'certs', 'admin-key.pem' ] | path_join }}"
read_only: true
swarm_safe: false
mounts:
- service: indexer
target: /usr/share/wazuh-indexer/config/certs/admin-key.pem
+126
View File
@@ -0,0 +1,126 @@
---
- include_tasks: utils/once/flag.yml
- name: "🧩 load docker for {{ application_id }}"
include_role:
name: sys-stk-backend
vars:
compose_handlers_flush: false
- name: "📁 Ensure {{ WAZUH_CERTS_DIR_HOST }} exists for the cert-generator bind mount"
ansible.builtin.file:
path: "{{ WAZUH_CERTS_DIR_HOST }}"
state: directory
mode: "0755"
- name: "📄 Render node topology for wazuh-certs-generator"
stack_host_template:
src: certs.yml.j2
dest: "{{ WAZUH_CERTS_CONFIG_HOST }}"
mode: "0644"
notify: compose-up
- name: "📄 Render Wazuh config files bind-mounted into manager/indexer/dashboard"
stack_host_template:
src: "{{ item }}.j2"
dest: "{{ [ WAZUH_INSTANCE_DIR, item ] | path_join }}"
mode: "0644"
loop:
- wazuh_manager.conf
- wazuh.indexer.yml
- internal_users.yml
- config.yml
- roles_mapping.yml
- opensearch_dashboards.yml
- wazuh.yml
notify: compose-up
- name: "📄 Render per-service env files for the Wazuh manager and dashboard"
stack_host_template:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
mode: "0640"
force: true
loop:
- { src: manager.env.j2, dest: "{{ WAZUH_MANAGER_ENV_FILE }}" }
- { src: dashboard.env.j2, dest: "{{ WAZUH_DASHBOARD_ENV_FILE }}" }
loop_control:
label: "{{ item.src }}"
notify: compose-up
- name: "📋 Copy static Wazuh indexer roles.yml"
ansible.builtin.copy: # nocheck: stack-host-consumer (already gated by `when: IS_STACK_HOST | bool` below)
src: roles.yml
dest: "{{ [ WAZUH_INSTANCE_DIR, 'roles.yml' ] | path_join }}"
mode: "0644"
when: IS_STACK_HOST | bool
notify: compose-up
- name: "📋 Copy static Wazuh entrypoint scripts"
ansible.builtin.copy: # nocheck: stack-host-consumer (already gated by `when: IS_STACK_HOST | bool` below)
src: "shell/{{ item }}"
dest: "{{ [ WAZUH_INSTANCE_DIR, item ] | path_join }}"
mode: "0555"
loop:
- certs-entrypoint.sh
- indexer-entrypoint.sh
when: IS_STACK_HOST | bool
notify: compose-up
- name: "🔐 Pre-generate the Wazuh certs before the swarm stack deploys"
include_tasks: certs/swarm.yml
when:
- IS_STACK_HOST | bool
- DEPLOYMENT_MODE == 'swarm'
- name: "🔧 Set vm.max_map_count for the Wazuh indexer (OpenSearch requirement)"
ansible.posix.sysctl:
name: vm.max_map_count
value: "262144"
state: present
reload: true
sysctl_file: "{{ WAZUH_SYSCTL_CONF }}"
when: not (DOCKER_IN_CONTAINER | bool)
- name: "🔄 Flush compose-up before front-proxy can shadow application_id"
ansible.builtin.meta: flush_handlers
- name: "📂 Include front proxy for {{ application_id }}"
include_role:
name: sys-stk-front-proxy
vars:
http_port: "{{ WAZUH_DASHBOARD_HTTP_PORT }}"
webserver_proxy_service_key: "{{ 'dashboard' if DEPLOYMENT_MODE == 'swarm' else (application_id | get_entity_name) }}"
proxy_internal_port: "{{ WAZUH_DASHBOARD_INTERNAL_PORT if DEPLOYMENT_MODE == 'swarm' else '' }}"
- name: "⏳ Wait for Wazuh indexer to answer HTTPS requests (security plugin loaded)"
uri_retry:
url: "{{ WAZUH_INDEXER_INTERNAL_API_URL }}/_cluster/health"
method: GET
validate_certs: false
status_code: [200, 401]
register: wazuh_indexer_ready
until: wazuh_indexer_ready is succeeded
retries: 60
delay: 5
- name: "🔄 Apply Wazuh indexer security config via securityadmin.sh"
include_tasks: 01_securityadmin.yml
- name: "✅ Confirm indexer admin credentials are live post-securityadmin.sh"
uri_retry:
url: "{{ WAZUH_INDEXER_INTERNAL_API_URL }}/_cluster/health"
method: GET
user: "{{ WAZUH_INDEXER_ADMIN_USERNAME }}"
password: "{{ WAZUH_INDEXER_ADMIN_PASSWORD }}"
force_basic_auth: true
validate_certs: false
status_code: 200
return_content: true
register: wazuh_indexer_health
until: >-
wazuh_indexer_health.json is defined and
wazuh_indexer_health.json.status in ['green', 'yellow']
retries: 30
delay: 5
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
@@ -0,0 +1,19 @@
---
- name: "▶️ Run securityadmin.sh inside the indexer container"
ansible.builtin.shell:
cmd: >-
container exec -e JAVA_HOME=/usr/share/wazuh-indexer/jdk {{ WAZUH_INDEXER_CONTAINER_ADDRESS }}
/usr/share/wazuh-indexer/plugins/opensearch-security/tools/securityadmin.sh
-cd {{ WAZUH_INDEXER_SECURITY_DIR_CONTAINER }}/
-icl
-key {{ WAZUH_INDEXER_CERTS_DIR_CONTAINER }}/admin-key.pem
-cert {{ WAZUH_INDEXER_CERTS_DIR_CONTAINER }}/admin.pem
-cacert {{ WAZUH_INDEXER_CERTS_DIR_CONTAINER }}/root-ca.pem
-nhnv
chdir: "{{ WAZUH_INSTANCE_DIR }}"
register: wazuh_securityadmin
changed_when: "'Done with success' in wazuh_securityadmin.stdout"
failed_when: wazuh_securityadmin.rc != 0 or 'Done with success' not in wazuh_securityadmin.stdout
retries: 10
delay: 10
until: wazuh_securityadmin.rc == 0
+114
View File
@@ -0,0 +1,114 @@
---
- name: "🔍 Read the Wazuh certs completion marker"
ansible.builtin.slurp:
src: "{{ WAZUH_CERTS_MARKER_HOST }}"
register: wazuh_certs_marker
failed_when: false
- name: "🔍 Checksum the rendered Wazuh certs.yml"
ansible.builtin.stat:
path: "{{ WAZUH_CERTS_CONFIG_HOST }}"
checksum_algorithm: sha256
register: wazuh_certs_config
- name: "🔍 Stat every Wazuh cert file the stack bind-mounts"
ansible.builtin.stat:
path: "{{ item }}"
loop: "{{ WAZUH_CERTS_REQUIRED_FILES }}"
register: wazuh_certs_files
- name: "🔍 List the services of a running Wazuh stack"
ansible.builtin.command:
argv:
- container
- service
- ls
- --quiet
- --filter
- "label=com.docker.stack.namespace={{ application_id | get_entity_name }}"
register: wazuh_certs_stack
changed_when: false
- name: "🧮 Decide whether the Wazuh certs are current"
vars:
expected: "{{ wazuh_certs_config.stat.checksum }} {{ WAZUH_CERTS_TOOL_VERSION }}"
recorded: "{{ wazuh_certs_marker.content | default('') | b64decode | trim }}"
present: >-
{{ wazuh_certs_files.results | selectattr('stat.size', 'defined') | selectattr('stat.size', 'gt', 0) | list | length }}
ansible.builtin.set_fact:
wazuh_certs_expected: "{{ expected }}"
wazuh_certs_recorded: "{{ recorded }}"
wazuh_certs_present: "{{ present | int }}"
wazuh_certs_current: "{{ present | int == WAZUH_CERTS_REQUIRED_FILES | length and recorded == expected }}"
- name: "⛔ Refuse to rotate the Wazuh CA under a running stack"
ansible.builtin.fail:
msg: >-
Wazuh TLS certs on {{ inventory_hostname }} are not current, and the stack
'{{ application_id | get_entity_name }}' is running, so they were NOT regenerated: that would
swap the CA under the live services.
Marker {{ WAZUH_CERTS_MARKER_HOST }} reads '{{ wazuh_certs_recorded or 'missing' }}',
expected '{{ wazuh_certs_expected }}' (sha256 of certs.yml, then tool version);
{{ wazuh_certs_present }}/{{ WAZUH_CERTS_REQUIRED_FILES | length }} cert files in
{{ WAZUH_CERTS_DIR_HOST }} exist and are non-empty.
To regenerate: run 'docker stack rm {{ application_id | get_entity_name }}', then redeploy.
To keep certs you know are good (a host deployed before this marker existed): write
'{{ wazuh_certs_expected }}' to {{ WAZUH_CERTS_MARKER_HOST }}, then redeploy.
when:
- not (wazuh_certs_current | bool)
- wazuh_certs_stack.stdout | trim | length > 0
- name: "⬇️ Pull the Wazuh certs generator image on the stack host"
ansible.builtin.command:
argv:
- container
- pull
- "{{ WAZUH_CERTS_GENERATOR_IMAGE }}"
register: wazuh_certs_pull
retries: 10
delay: 6
until: wazuh_certs_pull.rc == 0
changed_when: false
when: not (wazuh_certs_current | bool)
- name: "🔐 Run the Wazuh certs generator once on the stack host"
ansible.builtin.shell: |
set -euo pipefail
container ps -aq --filter {{ ('name=^' ~ WAZUH_CERTS_GENERATOR_CONTAINER ~ '$') | quote }} | xargs -r container rm -f
timeout -k 30 {{ lookup('timeout', 600) | int }} container run --rm \
--name {{ WAZUH_CERTS_GENERATOR_CONTAINER | quote }} \
--entrypoint {{ WAZUH_CERTS_ENTRYPOINT_TARGET | quote }} \
--env CERT_TOOL_VERSION={{ WAZUH_CERTS_TOOL_VERSION | quote }} \
--env WAZUH_MANAGER_NODE={{ WAZUH_MANAGER_CONTAINER | quote }} \
{% for mount in WAZUH_CERTS_GENERATOR_MOUNTS %}--mount {{ mount | quote }} {% endfor %}\
{{ WAZUH_CERTS_GENERATOR_IMAGE | quote }}
args:
executable: /bin/bash
register: wazuh_certs_generate
retries: 3
delay: 10
until: wazuh_certs_generate.rc == 0
when: not (wazuh_certs_current | bool)
- name: "✅ Verify the generated Wazuh certs and record the marker"
when: wazuh_certs_generate is not skipped
block:
- name: "🔍 Stat the Wazuh cert files the generator wrote"
ansible.builtin.stat:
path: "{{ item }}"
loop: "{{ WAZUH_CERTS_REQUIRED_FILES }}"
register: wazuh_certs_written
- name: "✅ Assert every Wazuh cert file exists and is non-empty"
ansible.builtin.assert:
that:
- >-
wazuh_certs_written.results | selectattr('stat.size', 'defined') | selectattr('stat.size', 'gt', 0)
| list | length == WAZUH_CERTS_REQUIRED_FILES | length
fail_msg: "The Wazuh certs generator exited 0 but left a required cert file missing or empty."
- name: "📝 Record the Wazuh certs completion marker"
ansible.builtin.copy:
content: "{{ wazuh_certs_expected }}\n"
dest: "{{ WAZUH_CERTS_MARKER_HOST }}"
mode: "0644"
+3
View File
@@ -0,0 +1,3 @@
---
- include_tasks: 00_core.yml
when: run_once_web_app_wazuh is not defined
@@ -0,0 +1,10 @@
nodes:
indexer:
- name: {{ WAZUH_INDEXER_CONTAINER }}
ip: {{ WAZUH_INDEXER_CONTAINER }}
server:
- name: {{ WAZUH_MANAGER_CONTAINER }}
ip: {{ WAZUH_MANAGER_CONTAINER }}
dashboard:
- name: {{ WAZUH_DASHBOARD_CONTAINER }}
ip: {{ WAZUH_DASHBOARD_CONTAINER }}
@@ -0,0 +1,72 @@
{% include 'roles/sys-svc-compose/templates/base.yml.j2' %}
{# Exception: swarm ignores depends_on, so in swarm tasks/certs/swarm.yml generates the certs before the stack deploys. #}
{% set certs_generator_dependency = {'certs_generator': {'condition': 'service_completed_successfully'}} if DEPLOYMENT_MODE == 'compose' else {} %}
{% if DEPLOYMENT_MODE == 'compose' %}
{% set service_name = 'certs_generator' %}
{% set docker_restart_policy = 'no' %}
{{ service_name }}:
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
{{ lookup('container_image', application_id, 'certs_generator') }}
{{ lookup('compose_only', 'container_name', WAZUH_CERTS_GENERATOR_CONTAINER) }}
entrypoint: ["{{ WAZUH_CERTS_ENTRYPOINT_TARGET }}"]
environment:
- CERT_TOOL_VERSION={{ WAZUH_CERTS_TOOL_VERSION }}
- WAZUH_MANAGER_NODE={{ WAZUH_MANAGER_CONTAINER }}
{{ lookup('container_volumes', service_name) | indent(4) }}
{% endif %}
{% set service_name = 'manager' %}
{% set docker_restart_policy = DOCKER_RESTART_POLICY %}
{% set docker_compose_env = WAZUH_MANAGER_ENV_FILE %}
{{ service_name }}:
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
{{ lookup('container_image', application_id, 'manager') }}
{{ lookup('compose_only', 'container_name', WAZUH_MANAGER_CONTAINER) }}
hostname: "{{ WAZUH_MANAGER_CONTAINER }}"
ulimits:
nofile:
soft: {{ lookup('config', application_id, 'services.manager.nofile') }}
hard: {{ lookup('config', application_id, 'services.manager.nofile') }}
{{ lookup('container_volumes', service_name) | indent(4) }}
{{ lookup('container_networks') | indent(4) }}
{{ lookup('container_depends_on', application_id, extra=certs_generator_dependency) | indent(4) }}
{% set service_name = 'indexer' %}
{% set docker_compose_env = '' %}
{{ service_name }}:
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
{{ lookup('container_image', application_id, 'indexer') }}
{{ lookup('compose_only', 'container_name', WAZUH_INDEXER_CONTAINER) }}
hostname: "{{ WAZUH_INDEXER_CONTAINER }}"
entrypoint: ["{{ WAZUH_INDEXER_ENTRYPOINT_TARGET }}"]
command: ["opensearchwrapper"]
{{ lookup('container_ports', ['indexer', 'http', DOCKER_BIND_HOST]) | indent(4) }}
environment:
- "OPENSEARCH_JAVA_OPTS=-Xms{{ WAZUH_INDEXER_HEAP }} -Xmx{{ WAZUH_INDEXER_HEAP }}"
ulimits:
nofile:
soft: {{ lookup('config', application_id, 'services.indexer.nofile') }}
hard: {{ lookup('config', application_id, 'services.indexer.nofile') }}
{{ lookup('container_volumes', service_name) | indent(4) }}
{{ lookup('container_networks') | indent(4) }}
{{ lookup('container_depends_on', application_id, extra=certs_generator_dependency) | indent(4) }}
{{ lookup('container_healthcheck', service_name) | indent(4) }}
{% set service_name = 'dashboard' %}
{% set docker_compose_env = WAZUH_DASHBOARD_ENV_FILE %}
{{ service_name }}:
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
{{ lookup('container_image', application_id, 'console') }}
{{ lookup('compose_only', 'container_name', WAZUH_DASHBOARD_CONTAINER) }}
hostname: "{{ WAZUH_DASHBOARD_CONTAINER }}"
{{ lookup('container_ports', ['console', 'http', DOCKER_BIND_HOST]) | indent(4) }}
{{ lookup('container_volumes', service_name) | indent(4) }}
{{ lookup('container_networks') | indent(4) }}
{{ lookup('container_depends_on', application_id, extra={
'indexer': {'condition': 'service_healthy'},
'manager': {'condition': 'service_started'},
}) | indent(4) }}
{{ lookup('compose_volumes') }}
{{ lookup('compose_networks') }}
@@ -0,0 +1,65 @@
---
_meta:
type: "config"
config_version: 2
config:
dynamic:
http:
anonymous_auth_enabled: false
authc:
basic_internal_auth_domain:
description: "HTTP basic auth against internal_users.yml (service accounts only)."
http_enabled: true
transport_enabled: true
order: 0
http_authenticator:
type: basic
challenge: false
authentication_backend:
type: internal
{% if WAZUH_OIDC_ENABLED %}
openid_auth_domain:
description: "Native OIDC against Keycloak's discovery endpoint (no oauth2-proxy sidecar)."
http_enabled: true
transport_enabled: true
order: 1
http_authenticator:
type: openid
challenge: true
config:
subject_key: "{{ OIDC.ATTRIBUTES.USERNAME }}"
roles_key: groups
openid_connect_url: "{{ OIDC.CLIENT.DISCOVERY_DOCUMENT }}"
authentication_backend:
type: noop
{% endif %}
{% if WAZUH_LDAP_ENABLED %}
authz:
ldap_authz_domain:
description: "Authorization only: enrich the authenticated user with backend_roles from LDAP group membership. Never used to verify a password."
http_enabled: true
transport_enabled: true
authorization_backend:
type: ldap
config:
hosts:
- "{{ LDAP.SERVER.URI | regex_replace('^ldaps?://', '') }}"
enable_ssl: "{{ LDAP.SERVER.SECURITY == 'ldaps' }}"
enable_start_tls: "{{ LDAP.SERVER.SECURITY == 'starttls' }}"
verify_hostnames: true
bind_dn: "{{ LDAP.DN.ADMINISTRATOR.DATA }}"
password: "{{ LDAP.BIND_CREDENTIAL }}"
userbase: "{{ LDAP.DN.OU.USERS }}"
usersearch: "({{ LDAP.USER.ATTRIBUTES.ID }}={0})"
username_attribute: "{{ LDAP.USER.ATTRIBUTES.ID }}"
rolebase: "{{ LDAP.DN.OU.ROLES }}"
rolesearch: "(member={0})"
userroleattribute: null
userrolename: "disabled"
rolename: "cn"
resolve_nested_roles: true
skip_users:
- "{{ WAZUH_INDEXER_ADMIN_USERNAME }}"
- "{{ WAZUH_DASHBOARD_SERVICE_USERNAME }}"
{% endif %}
@@ -0,0 +1,7 @@
API_PASSWORD={{ WAZUH_WUI_PASSWORD | dotenv_quote }}
API_USERNAME={{ WAZUH_WUI_USERNAME }}
DASHBOARD_PASSWORD={{ WAZUH_DASHBOARD_SERVICE_PASSWORD | dotenv_quote }}
DASHBOARD_USERNAME={{ WAZUH_DASHBOARD_SERVICE_USERNAME }}
INDEXER_PASSWORD={{ WAZUH_INDEXER_ADMIN_PASSWORD | dotenv_quote }}
INDEXER_USERNAME={{ WAZUH_INDEXER_ADMIN_USERNAME }}
WAZUH_API_URL=https://{{ WAZUH_MANAGER_CONTAINER }}
@@ -0,0 +1,16 @@
---
_meta:
type: "internalusers"
config_version: 2
"{{ WAZUH_INDEXER_ADMIN_USERNAME }}":
hash: "{{ WAZUH_INDEXER_ADMIN_HASH }}"
reserved: true
backend_roles:
- "admin"
description: "Bootstrap/service account used by securityadmin.sh, health checks, and manager/dashboard INDEXER_PASSWORD."
"{{ WAZUH_DASHBOARD_SERVICE_USERNAME }}":
hash: "{{ WAZUH_DASHBOARD_SERVICE_HASH }}"
reserved: true
description: "Service account wazuh.dashboard uses for its own backend calls to wazuh.indexer."
@@ -0,0 +1,9 @@
API_PASSWORD={{ WAZUH_WUI_PASSWORD | dotenv_quote }}
API_USERNAME={{ WAZUH_WUI_USERNAME }}
FILEBEAT_SSL_VERIFICATION_MODE=full
INDEXER_PASSWORD={{ WAZUH_INDEXER_ADMIN_PASSWORD | dotenv_quote }}
INDEXER_URL={{ WAZUH_INDEXER_URL }}
INDEXER_USERNAME={{ WAZUH_INDEXER_ADMIN_USERNAME }}
SSL_CERTIFICATE={{ lookup('volume', application_id, 'manager_filebeat_pem').target }}
SSL_CERTIFICATE_AUTHORITIES={{ lookup('volume', application_id, 'root_ca_manager_pem').target }}
SSL_KEY={{ lookup('volume', application_id, 'manager_filebeat_key_pem').target }}
@@ -0,0 +1,22 @@
server.host: 0.0.0.0
server.port: {{ WAZUH_DASHBOARD_INTERNAL_PORT }}
server.ssl.enabled: false
opensearch.hosts: {{ WAZUH_INDEXER_URL }}
opensearch.ssl.verificationMode: certificate
opensearch.ssl.certificateAuthorities: ["/usr/share/wazuh-dashboard/certs/root-ca.pem"]
opensearch.requestHeadersWhitelist: ["securitytenant", "Authorization"]
opensearch_security.multitenancy.enabled: false
opensearch_security.readonly_mode.roles: ["kibana_read_only"]
uiSettings.overrides.defaultRoute: /app/home
opensearch_security.cookie.ttl: 900000
opensearch_security.session.ttl: 900000
opensearch_security.session.keepalive: true
{% if WAZUH_OIDC_ENABLED %}
opensearch_security.auth.type: "openid"
opensearch_security.openid.connect_url: "{{ OIDC.CLIENT.DISCOVERY_DOCUMENT }}"
opensearch_security.openid.client_id: "{{ OIDC.CLIENT.ID }}"
opensearch_security.openid.client_secret: "{{ OIDC.CLIENT.SECRET }}"
opensearch_security.openid.base_redirect_url: "{{ lookup('tls', application_id, 'url.base') }}"
opensearch_security.openid.scope: "openid profile email groups"
opensearch_security.openid.logout_url: "{{ OIDC.CLIENT.LOGOUT_URL }}"
{% endif %}
@@ -0,0 +1,36 @@
ADMIN_PASSWORD={{ lookup('users', 'administrator').password | dotenv_quote }}
ADMIN_USERNAME={{ lookup('users', 'administrator').username | dotenv_quote }}
APP_BASE_URL={{ lookup('tls', application_id, 'url.base') | dotenv_quote }}
BIBER_PASSWORD={{ lookup('users', 'biber').password | dotenv_quote }}
BIBER_USERNAME={{ lookup('users', 'biber').username | dotenv_quote }}
CANONICAL_DOMAIN={{ lookup('domain', application_id) | dotenv_quote }}
KEYCLOAK_ADMIN_CLI_CLIENT_ID={{ OIDC.ADMIN.CLI_CLIENT_ID | dotenv_quote }}
KEYCLOAK_ADMIN_REALM={{ OIDC.ADMIN.REALM | dotenv_quote }}
KEYCLOAK_BASE_URL={{ OIDC.URL | dotenv_quote }}
KEYCLOAK_REALM_NAME={{ OIDC.CLIENT.REALM | dotenv_quote }}
LOGOUT_SERVICE_ENABLED={{ lookup('config', application_id, 'services.logout.enabled') | string | lower }}
MATOMO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matomo.enabled') | string | lower }}
OIDC_ISSUER_URL={{ OIDC.CLIENT.ISSUER_URL | dotenv_quote }}
# nocheck: persona-block-rationale with sso off the dashboard only accepts the internal admin/kibanaserver HTTP basic accounts, and the persona helper drives Keycloak login only.
PERSONA_ADMINISTRATOR_BLOCKED={{ (not (WAZUH_OIDC_ENABLED | bool)) | string | lower }}
RBAC_GROUP_PATH_PREFIX={{ ('/' ~ (RBAC.GROUP.NAME | trim('/')) ~ '/' ~ application_id ~ '/') | dotenv_quote }}
SSO_SERVICE_ENABLED={{ WAZUH_OIDC_ENABLED | string | lower }}
SUPER_ADMIN_PASSWORD={{ (lookup('config', 'web-app-keycloak', 'secrets.credentials.administrator_password') | dotenv_quote) if (WAZUH_OIDC_ENABLED | bool) else '""' }}
SUPER_ADMIN_USERNAME={{ (lookup('config', 'web-app-keycloak', 'services.keycloak.accounts.system.username') | dotenv_quote) if (WAZUH_OIDC_ENABLED | bool) else '""' }}
@@ -0,0 +1,34 @@
---
_meta:
type: "rolesmapping"
config_version: 2
all_access:
reserved: false
backend_roles:
- "{{ WAZUH_RBAC_ADMIN_GROUP }}"
- "{{ WAZUH_RBAC_ADMIN_LDAP_CN }}"
- "admin"
security_analyst:
reserved: false
backend_roles:
- "{{ WAZUH_RBAC_ANALYST_GROUP }}"
- "{{ WAZUH_RBAC_ANALYST_LDAP_CN }}"
readonly_auditor:
reserved: false
backend_roles:
- "{{ WAZUH_RBAC_READONLY_GROUP }}"
- "{{ WAZUH_RBAC_READONLY_LDAP_CN }}"
kibana_read_only:
reserved: false
backend_roles:
- "{{ WAZUH_RBAC_READONLY_GROUP }}"
- "{{ WAZUH_RBAC_READONLY_LDAP_CN }}"
kibana_server:
reserved: false
users:
- "{{ WAZUH_DASHBOARD_SERVICE_USERNAME }}"
@@ -0,0 +1,37 @@
network.host: "0.0.0.0"
node.name: "{{ WAZUH_INDEXER_CONTAINER }}"
cluster.name: "wazuh-cluster"
path.data: {{ lookup('volume', application_id, 'indexer_data').target }}
path.logs: /var/log/wazuh-indexer
discovery.type: single-node
compatibility.override_main_response_version: true
plugins.security.ssl.http.pemcert_filepath: {{ WAZUH_INDEXER_CERTS_DIR_CONTAINER }}/wazuh.indexer.pem
plugins.security.ssl.http.pemkey_filepath: {{ WAZUH_INDEXER_CERTS_DIR_CONTAINER }}/wazuh.indexer.key
plugins.security.ssl.http.pemtrustedcas_filepath: {{ WAZUH_INDEXER_CERTS_DIR_CONTAINER }}/root-ca.pem
plugins.security.ssl.transport.pemcert_filepath: {{ WAZUH_INDEXER_CERTS_DIR_CONTAINER }}/wazuh.indexer.pem
plugins.security.ssl.transport.pemkey_filepath: {{ WAZUH_INDEXER_CERTS_DIR_CONTAINER }}/wazuh.indexer.key
plugins.security.ssl.transport.pemtrustedcas_filepath: {{ WAZUH_INDEXER_CERTS_DIR_CONTAINER }}/root-ca.pem
plugins.security.ssl.http.enabled: true
plugins.security.ssl.transport.enforce_hostname_verification: false
plugins.security.ssl.transport.resolve_hostname: false
plugins.security.ssl.http.enabled_ciphers:
- "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
- "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"
- "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"
- "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"
plugins.security.ssl.http.enabled_protocols:
- "TLSv1.2"
plugins.security.authcz.admin_dn:
- "CN=admin,OU=Wazuh,O=Wazuh,L=California,C=US"
plugins.security.check_snapshot_restore_write_privileges: true
plugins.security.enable_snapshot_restore_privilege: true
plugins.security.nodes_dn:
- "CN={{ WAZUH_INDEXER_CONTAINER }},OU=Wazuh,O=Wazuh,L=California,C=US"
plugins.security.restapi.roles_enabled:
- "all_access"
- "security_rest_api_access"
plugins.security.system_indices.enabled: true
plugins.security.system_indices.indices: [".opendistro-alerting-config", ".opendistro-alerting-alert*", ".opendistro-anomaly-results*", ".opendistro-anomaly-detector*", ".opendistro-anomaly-checkpoints", ".opendistro-anomaly-detection-state", ".opendistro-reports-*", ".opendistro-notifications-*", ".opendistro-notebooks", ".opensearch-observability", ".opendistro-asynchronous-search-response*", ".replication-metadata-store"]
plugins.security.allow_default_init_securityindex: true
plugins.security.cache.ttl_minutes: 0
cluster.routing.allocation.disk.threshold_enabled: false
@@ -0,0 +1,7 @@
hosts:
- default:
url: "https://{{ WAZUH_MANAGER_CONTAINER }}"
port: {{ lookup('config', application_id, 'services.manager.ports.internal.api') }}
username: "{{ WAZUH_WUI_USERNAME }}"
password: "{{ WAZUH_WUI_PASSWORD }}"
run_as: true
@@ -0,0 +1,251 @@
<ossec_config>
<global>
<jsonout_output>yes</jsonout_output>
<alerts_log>yes</alerts_log>
<logall>no</logall>
<logall_json>no</logall_json>
<email_notification>no</email_notification>
<agents_disconnection_time>10m</agents_disconnection_time>
<agents_disconnection_alert_time>0</agents_disconnection_alert_time>
</global>
<alerts>
<log_alert_level>3</log_alert_level>
<email_alert_level>12</email_alert_level>
</alerts>
<logging>
<log_format>plain</log_format>
</logging>
<remote>
<connection>secure</connection>
<port>{{ lookup('config', application_id, 'services.manager.ports.internal.agent_events') }}</port>
<protocol>tcp</protocol>
<queue_size>131072</queue_size>
</remote>
<rootcheck>
<disabled>no</disabled>
<check_files>yes</check_files>
<check_trojans>yes</check_trojans>
<check_dev>yes</check_dev>
<check_sys>yes</check_sys>
<check_pids>yes</check_pids>
<check_ports>yes</check_ports>
<check_if>yes</check_if>
<frequency>43200</frequency>
<rootkit_files>etc/rootcheck/rootkit_files.txt</rootkit_files>
<rootkit_trojans>etc/rootcheck/rootkit_trojans.txt</rootkit_trojans>
<skip_nfs>yes</skip_nfs>
</rootcheck>
<wodle name="cis-cat">
<disabled>yes</disabled>
<timeout>1800</timeout>
<interval>1d</interval>
<scan-on-start>yes</scan-on-start>
<java_path>wodles/java</java_path>
<ciscat_path>wodles/ciscat</ciscat_path>
</wodle>
<wodle name="osquery">
<disabled>yes</disabled>
<run_daemon>yes</run_daemon>
<log_path>/var/log/osquery/osqueryd.results.log</log_path>
<config_path>/etc/osquery/osquery.conf</config_path>
<add_labels>yes</add_labels>
</wodle>
<wodle name="syscollector">
<disabled>no</disabled>
<interval>1h</interval>
<scan_on_start>yes</scan_on_start>
<hardware>yes</hardware>
<os>yes</os>
<network>yes</network>
<packages>yes</packages>
<ports all="yes">yes</ports>
<processes>yes</processes>
<synchronization>
<max_eps>10</max_eps>
</synchronization>
</wodle>
<sca>
<enabled>yes</enabled>
<scan_on_start>yes</scan_on_start>
<interval>12h</interval>
<skip_nfs>yes</skip_nfs>
</sca>
<vulnerability-detection>
<enabled>yes</enabled>
<index-status>yes</index-status>
<feed-update-interval>60m</feed-update-interval>
</vulnerability-detection>
<indexer>
<enabled>yes</enabled>
<hosts>
<host>{{ WAZUH_INDEXER_URL }}</host>
</hosts>
<ssl>
<certificate_authorities>
<ca>{{ lookup('volume', application_id, 'root_ca_manager_pem').target }}</ca>
</certificate_authorities>
<certificate>{{ lookup('volume', application_id, 'manager_filebeat_pem').target }}</certificate>
<key>{{ lookup('volume', application_id, 'manager_filebeat_key_pem').target }}</key>
</ssl>
</indexer>
<syscheck>
<disabled>no</disabled>
<frequency>43200</frequency>
<scan_on_start>yes</scan_on_start>
<alert_new_files>yes</alert_new_files>
<auto_ignore frequency="10" timeframe="3600">no</auto_ignore>
<directories>/etc,/usr/bin,/usr/sbin</directories>
<directories>/bin,/sbin,/boot</directories>
<ignore>/etc/mtab</ignore>
<ignore>/etc/hosts.deny</ignore>
<ignore>/etc/mail/statistics</ignore>
<ignore>/etc/random-seed</ignore>
<ignore>/etc/random.seed</ignore>
<ignore>/etc/adjtime</ignore>
<ignore>/etc/httpd/logs</ignore>
<ignore>/etc/utmpx</ignore>
<ignore>/etc/wtmpx</ignore>
<ignore>/etc/cups/certs</ignore>
<ignore>/etc/dumpdates</ignore>
<ignore>/etc/svc/volatile</ignore>
<ignore type="sregex">.log$|.swp$</ignore>
<nodiff>/etc/ssl/private.key</nodiff>
<skip_nfs>yes</skip_nfs>
<skip_dev>yes</skip_dev>
<skip_proc>yes</skip_proc>
<skip_sys>yes</skip_sys>
<process_priority>10</process_priority>
<max_eps>100</max_eps>
<synchronization>
<enabled>yes</enabled>
<interval>5m</interval>
<max_interval>1h</max_interval>
<max_eps>10</max_eps>
</synchronization>
</syscheck>
<global>
<white_list>127.0.0.1</white_list>
<white_list>^localhost.localdomain$</white_list>
</global>
<command>
<name>disable-account</name>
<executable>disable-account</executable>
<timeout_allowed>yes</timeout_allowed>
</command>
<command>
<name>restart-wazuh</name>
<executable>restart-wazuh</executable>
</command>
<command>
<name>firewall-drop</name>
<executable>firewall-drop</executable>
<timeout_allowed>yes</timeout_allowed>
</command>
<command>
<name>host-deny</name>
<executable>host-deny</executable>
<timeout_allowed>yes</timeout_allowed>
</command>
<command>
<name>route-null</name>
<executable>route-null</executable>
<timeout_allowed>yes</timeout_allowed>
</command>
<command>
<name>win_route-null</name>
<executable>route-null.exe</executable>
<timeout_allowed>yes</timeout_allowed>
</command>
<command>
<name>netsh</name>
<executable>netsh.exe</executable>
<timeout_allowed>yes</timeout_allowed>
</command>
<localfile>
<log_format>command</log_format>
<command>df -P</command>
<frequency>360</frequency>
</localfile>
<localfile>
<log_format>full_command</log_format>
<command>last -n 20</command>
<frequency>360</frequency>
</localfile>
<ruleset>
<decoder_dir>ruleset/decoders</decoder_dir>
<rule_dir>ruleset/rules</rule_dir>
<rule_exclude>0215-policy_rules.xml</rule_exclude>
<list>etc/lists/audit-keys</list>
<list>etc/lists/amazon/aws-eventnames</list>
<list>etc/lists/security-eventchannel</list>
<list>etc/lists/malicious-ioc/malicious-ip</list>
<list>etc/lists/malicious-ioc/malicious-domains</list>
<list>etc/lists/malicious-ioc/malware-hashes</list>
<decoder_dir>etc/decoders</decoder_dir>
<rule_dir>etc/rules</rule_dir>
</ruleset>
<rule_test>
<enabled>yes</enabled>
<threads>1</threads>
<max_sessions>64</max_sessions>
<session_timeout>15m</session_timeout>
</rule_test>
<auth>
<disabled>no</disabled>
<port>{{ lookup('config', application_id, 'services.manager.ports.internal.agent_registration') }}</port>
<use_source_ip>no</use_source_ip>
<purge>yes</purge>
<use_password>no</use_password>
<ciphers>HIGH:!ADH:!EXP:!MD5:!RC4:!3DES:!CAMELLIA:@STRENGTH</ciphers>
<ssl_verify_host>no</ssl_verify_host>
<ssl_manager_cert>etc/sslmanager.cert</ssl_manager_cert>
<ssl_manager_key>etc/sslmanager.key</ssl_manager_key>
<ssl_auto_negotiate>no</ssl_auto_negotiate>
</auth>
<cluster>
<name>wazuh</name>
<node_name>node01</node_name>
<node_type>master</node_type>
<key>{{ WAZUH_CLUSTER_KEY }}</key>
<port>1516</port>
<bind_addr>0.0.0.0</bind_addr>
<nodes>
<node>{{ WAZUH_MANAGER_CONTAINER }}</node>
</nodes>
<hidden>no</hidden>
<disabled>yes</disabled>
</cluster>
</ossec_config>
<ossec_config>
<localfile>
<log_format>syslog</log_format>
<location>/var/ossec/logs/active-responses.log</location>
</localfile>
</ossec_config>
+73
View File
@@ -0,0 +1,73 @@
---
application_id: "web-app-wazuh"
WAZUH_MANAGER_CONTAINER: "{{ lookup('config', application_id, 'services.manager.name') }}"
WAZUH_INDEXER_CONTAINER: "{{ lookup('config', application_id, 'services.indexer.name') }}"
WAZUH_INDEXER_CONTAINER_ADDRESS: "{{ lookup('container_address', application_id, 'indexer') }}"
WAZUH_INDEXER_HTTP_PORT: "{{ lookup('config', application_id, 'services.indexer.ports.local.http') }}"
WAZUH_INDEXER_INTERNAL_PORT: "{{ lookup('config', application_id, 'services.indexer.ports.internal.http') }}"
WAZUH_INDEXER_URL: "https://{{ WAZUH_INDEXER_CONTAINER }}:{{ WAZUH_INDEXER_INTERNAL_PORT }}"
WAZUH_INDEXER_HEAP: "{{ lookup('service_memory_mb', application_id, 'indexer', 0.5) }}m"
WAZUH_DASHBOARD_CONTAINER: "{{ lookup('config', application_id, 'services.console.name') }}"
WAZUH_DASHBOARD_HTTP_PORT: "{{ lookup('config', application_id, 'services.console.ports.local.http') }}"
WAZUH_DASHBOARD_INTERNAL_PORT: "{{ lookup('config', application_id, 'services.console.ports.internal.http') }}"
WAZUH_CERTS_GENERATOR_CONTAINER: "{{ lookup('config', application_id, 'services.certs_generator.name') }}"
WAZUH_CERTS_GENERATOR_IMAGE: "{{ lookup('image', application_id, 'certs_generator', tag_only=True) }}"
WAZUH_CERTS_GENERATOR_MOUNTS:
- "type=bind,source={{ WAZUH_CERTS_CONFIG_HOST }},target={{ lookup('volume', application_id, 'certs_generator_config').target }},readonly"
- "type=bind,source={{ WAZUH_CERTS_ENTRYPOINT_HOST }},target={{ WAZUH_CERTS_ENTRYPOINT_TARGET }},readonly"
- "type=bind,source={{ WAZUH_CERTS_DIR_HOST }},target={{ lookup('volume', application_id, 'wazuh_certs_dir').target }}"
WAZUH_CERTS_TOOL_VERSION: "{{ lookup('config', application_id, 'services.manager.version') | regex_replace('^(\\d+\\.\\d+).*$', '\\1') }}"
WAZUH_INSTANCE_DIR: "{{ lookup('container', application_id, 'directories.instance') }}"
WAZUH_MANAGER_ENV_FILE: "{{ [lookup('container', application_id, 'directories.env'), 'manager.env'] | path_join }}"
WAZUH_DASHBOARD_ENV_FILE: "{{ [lookup('container', application_id, 'directories.env'), 'dashboard.env'] | path_join }}"
WAZUH_CERTS_DIR_HOST: "{{ [ WAZUH_INSTANCE_DIR, 'certs' ] | path_join }}"
WAZUH_CERTS_CONFIG_HOST: "{{ [ WAZUH_INSTANCE_DIR, 'certs.yml' ] | path_join }}"
WAZUH_CERTS_ENTRYPOINT_HOST: "{{ [ WAZUH_INSTANCE_DIR, 'certs-entrypoint.sh' ] | path_join }}"
WAZUH_CERTS_ENTRYPOINT_TARGET: "{{ lookup('volume', application_id, 'certs_generator_entrypoint').target }}"
WAZUH_INDEXER_ENTRYPOINT_TARGET: "{{ lookup('volume', application_id, 'indexer_entrypoint').target }}"
WAZUH_CERTS_MARKER_HOST: "{{ [ WAZUH_INSTANCE_DIR, 'certs.complete' ] | path_join }}"
WAZUH_CERTS_REQUIRED_FILES:
- "{{ [ WAZUH_CERTS_DIR_HOST, 'root-ca.pem' ] | path_join }}"
- "{{ [ WAZUH_CERTS_DIR_HOST, 'root-ca-manager.pem' ] | path_join }}"
- "{{ [ WAZUH_CERTS_DIR_HOST, 'admin.pem' ] | path_join }}"
- "{{ [ WAZUH_CERTS_DIR_HOST, 'admin-key.pem' ] | path_join }}"
- "{{ [ WAZUH_CERTS_DIR_HOST, WAZUH_MANAGER_CONTAINER ~ '.pem' ] | path_join }}"
- "{{ [ WAZUH_CERTS_DIR_HOST, WAZUH_MANAGER_CONTAINER ~ '-key.pem' ] | path_join }}"
- "{{ [ WAZUH_CERTS_DIR_HOST, WAZUH_INDEXER_CONTAINER ~ '.pem' ] | path_join }}"
- "{{ [ WAZUH_CERTS_DIR_HOST, WAZUH_INDEXER_CONTAINER ~ '-key.pem' ] | path_join }}"
WAZUH_INDEXER_CERTS_DIR_CONTAINER: "/usr/share/wazuh-indexer/config/certs" # nocheck: container-var-name-lookup in-container path, not a container name
WAZUH_INDEXER_SECURITY_DIR_CONTAINER: "/usr/share/wazuh-indexer/config/opensearch-security" # nocheck: container-var-name-lookup in-container path, not a container name
WAZUH_SYSCTL_CONF: "/etc/sysctl.d/99-wazuh-indexer.conf"
WAZUH_INDEXER_INTERNAL_API_URL: "https://{{ DOCKER_REACH_HOST }}:{{ WAZUH_INDEXER_HTTP_PORT }}"
WAZUH_OIDC_ENABLED: "{{ lookup('config', application_id, 'services.sso.enabled') | bool }}"
WAZUH_LDAP_ENABLED: "{{ lookup('config', application_id, 'services.ldap.enabled') | bool }}"
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(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(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') }}"
WAZUH_CLUSTER_KEY: "{{ lookup('config', application_id, 'secrets.credentials.manager_cluster_key') }}"
WAZUH_RBAC_ADMIN_GROUP: "/{{ lookup('rbac_group_path', application_id=application_id, role='administrator') }}"
WAZUH_RBAC_ANALYST_GROUP: "/{{ lookup('rbac_group_path', application_id=application_id, role='security-analyst') }}"
WAZUH_RBAC_READONLY_GROUP: "/{{ lookup('rbac_group_path', application_id=application_id, role='readonly-auditor') }}"
WAZUH_RBAC_ADMIN_LDAP_CN: "{{ application_id }}-administrator"
WAZUH_RBAC_ANALYST_LDAP_CN: "{{ application_id }}-security-analyst"
WAZUH_RBAC_READONLY_LDAP_CN: "{{ application_id }}-readonly-auditor"
@@ -0,0 +1,98 @@
import unittest
import bcrypt
from plugins.filter.bcrypt_hash import FilterModule
class TestBcryptHash(unittest.TestCase):
def setUp(self):
self.f = FilterModule().filters()["bcrypt_hash"]
def test_hash_verifies_against_the_original_password(self):
password = "correct horse battery staple"
hashed = self.f(password)
self.assertTrue(
bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
)
def test_hash_does_not_verify_against_a_different_password(self):
hashed = self.f("password-one")
self.assertFalse(bcrypt.checkpw(b"password-two", hashed.encode("utf-8")))
def test_hash_is_randomized_salt_each_call(self):
password = "same-password"
self.assertNotEqual(self.f(password), self.f(password))
def test_hash_has_bcrypt_prefix(self):
hashed = self.f("some-password")
self.assertTrue(hashed.startswith(("$2b$", "$2a$")))
def test_64_character_password_hashes_successfully(self):
password = "A" * 64
hashed = self.f(password)
self.assertTrue(
bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
)
def test_72_byte_password_hashes_successfully(self):
password = "A" * 72
hashed = self.f(password)
self.assertTrue(
bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
)
def test_73_byte_password_raises(self):
with self.assertRaisesRegex(ValueError, "73 bytes.*at most 72 bytes"):
self.f("A" * 73)
def test_multibyte_password_over_72_bytes_raises(self):
password = "ä" * 37
self.assertEqual(len(password), 37)
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)
def test_empty_string_raises(self):
with self.assertRaises(ValueError):
self.f("")
if __name__ == "__main__":
unittest.main()