Investigate a Brute-Force Attack with Wazuh
Deploy Wazuh SIEM locally and investigate a simulated brute-force attack.
Introduction
30 Second Summary
Every time you log into an account online, there is a system watching for suspicious activity. The people who monitor these systems and investigate threats are called SOC analysts.
In this project, you will deploy a Wazuh SIEM stack in Docker and simulate a brute-force attack against it. You'll then write Python and PowerShell scripts to query and analyze the security alerts the system generates.
What You'll Build
A local Security Operations Center lab that detects brute-force authentication attacks in real time and provides scripts for automated alert analysis and incident reporting.
By the end of this project, you'll have:
- A running Wazuh SIEM stack (manager, indexer, dashboard) deployed locally in Docker.
- Detected brute-force alerts visible in the Wazuh dashboard with MITRE ATT&CK technique mapping.
- A Python script that queries the Wazuh Indexer API and produces a structured attack analysis, plus a PowerShell script that triages alerts by severity and generates a formal incident report.
- Secret Mission: A custom Wazuh detection rule you write from scratch to catch a specific attack pattern you define.
Are there any prerequisites?
No prior cybersecurity or SIEM experience is needed. This project walks you through installing and configuring everything from scratch, including Docker, PowerShell, and the Wazuh platform.
Set Up Your Environment
Your SOC lab needs three key tools working together: Docker Desktop with enough memory to run the Wazuh stack, PowerShell for incident response scripting, and Python with the requests library for API queries.
In this step, you will configure Docker Desktop's memory allocation, install PowerShell via Homebrew, and confirm Python is ready to go.
In this step, get ready to:
- Configure Docker Desktop to allocate enough memory for the Wazuh stack.
- Install PowerShell 7.6 on your Mac.
- Verify Python 3.10 and install the requests library.
Configure Docker Desktop memory
Wazuh's indexer component is memory-hungry. The Docker host needs at least 8 GB of RAM total, and Docker Desktop needs at least 4 GB allocated to it for the three Wazuh containers to run smoothly.
- Press Cmd+Space to open Spotlight Search.
- Type Docker Desktop and press Enter to open it.
- Click the gear icon in the top-right corner to open Settings.
- Select Resources from the left sidebar.
- Drag the Memory slider to at least 4 GB.
- Click Apply & Restart at the bottom of the panel.
Why 4 GB for Docker?
The Wazuh stack runs three containers: a manager, an indexer, and a dashboard. The indexer alone needs significant memory to store and search alert data. Allocating 4 GB to Docker leaves about 4 GB for macOS and other apps.
Docker Desktop will restart automatically. Wait for the Docker icon in your menu bar to show a steady green status before continuing.
Docker Desktop won't restart?
If Docker Desktop stalls or shows an error after applying memory changes, quit it completely from the menu bar icon and reopen it. The new memory allocation takes effect on restart.
Help me troubleshoot Docker Desktop not restarting after memory changes.
Install PowerShell 7.6 via Homebrew
PowerShell is a cross-platform scripting language you will use later to build an incident response report. On macOS, the easiest way to install it is through Homebrew.
- Check if PowerShell is already installed by running this command in your terminal:
pwsh --version
Check what you see in the terminal output, then pick the matching tab below.
✔️ I see PowerShell 7.6 or higher
Your output looks something like PowerShell 7.6.x. PowerShell is ready to go. Skip ahead to the next substep.
ⓧ I see an older version
You have PowerShell installed, but it is an older version. Upgrade it by running:
brew upgrade powershell
After the upgrade completes, verify the new version:
pwsh --version
You should now see PowerShell 7.6.x in the output.
ⓧ Command not found
PowerShell is not installed yet. Install it with Homebrew by running:
brew install powershell
This download may take a minute or two. Once it finishes, verify the install:
pwsh --version
You should see PowerShell 7.6.x in the output.
- Confirm the PowerShell shell starts by running:
pwsh
You should see a new prompt starting with PS. This confirms PowerShell is working.
- Return to your regular terminal by typing:
exit
Seeing 'brew: command not found'?
Homebrew itself is not installed on your Mac. Visit https://brew.sh and follow the one-line install command, then retry the PowerShell install.
Help me install Homebrew on macOS so I can install PowerShell.
Verify Python and install the requests library
Your Python script will use the requests library to talk to the Wazuh Indexer API over HTTPS. Let's confirm Python is ready and install that library.
- Confirm your Python version by running:
python3 --version
You should see Python 3.10.x in the output.
- Install the requests library by running:
pip3 install requests
What is the requests library?
The requests library simplifies making HTTP calls in Python. You will use it to authenticate with the Wazuh Indexer and query alert data through its REST API.
You should see output ending with Successfully installed requests (or a message saying the requirement is already satisfied).
Seeing 'pip3: command not found'?
On some macOS setups, pip3 is not on your PATH even though Python is installed. Try running python3 -m pip install requests instead. This invokes pip through Python directly.
Help me fix pip3 not being found on macOS.
✔️ Awesome, I've got everything!
Great. You have Docker Desktop with 4 GB of memory, PowerShell 7.6, and Python 3.10 with the requests library. All prerequisites are in place.
ⓧ I'd like to double check my setup
Run these three commands one at a time and confirm each output:
# Should show PowerShell 7.6.x
pwsh --version
# Should show Python 3.10.x
python3 --version
# Should print the requests version without errors
python3 -c "import requests; print(requests.__version__)"
If any of these fail, revisit the matching substep above.
Your environment is locked and loaded. Next up, you will deploy the full Wazuh SIEM stack using Docker and see a real security dashboard come to life.
Deploy the Wazuh SIEM Stack
Your environment is set up with Docker, Python, and PowerShell ready to go. Now it's time to deploy the heart of your SOC lab: a full Wazuh SIEM stack.
Wazuh runs as three Docker containers working together. The manager processes logs and triggers alerts. The indexer stores alert data for search. The dashboard gives you a visual interface to investigate security events.
Why Wazuh over other SIEMs?
Wazuh ships with thousands of pre-built detection rules already mapped to MITRE ATT&CK techniques. It runs comfortably on 8 GB of RAM, has no data ingestion cap, and is the most common free SIEM referenced in entry-level SOC analyst job postings.
In this step, get ready to:
- Clone the Wazuh Docker repository and generate TLS certificates.
- Start the three-container stack and verify all services are running.
- Access the Wazuh Dashboard and explore its security monitoring panels.
Clone the repository and generate certificates
Wazuh provides an official Docker deployment repository with pre-configured compose files. You will clone this repo and generate TLS certificates that secure communication between the three containers.
- Move to your Desktop by running this command:
cd ~/Desktop
- Clone the Wazuh Docker repository (branch v4.14.5) by running this command:
git clone https://github.com/wazuh/wazuh-docker.git -b v4.14.5
- Confirm the repository was cloned by running:
ls
You should see a wazuh-docker folder in the output.
Clone command failed?
- Make sure you have an internet connection. The clone downloads approximately 50 MB from GitHub.
- If you see a permission error, check that git is installed by running git --version.
- help me troubleshoot git clone failing for the Wazuh Docker repository
- Move into the single-node deployment directory by running these commands:
cd wazuh-docker/single-node/
The Wazuh containers need TLS certificates to encrypt their internal communication. The repository includes a generator container that creates these certificates automatically.
- Generate the TLS certificates by running this command:
docker compose -f generate-indexer-certs.yml run --rm generator
What does this command do?
- docker compose -f generate-indexer-certs.yml tells Docker Compose to use a specific compose file dedicated to certificate generation.
- run --rm generator starts the generator service, creates the certificates, then removes the container automatically when finished.
- The generated certificates are stored in a Docker volume so the main containers can use them.
You should see Docker pull the wazuh/wazuh-certs-generator image, then output messages as it creates the certificate files. The container exits automatically when finished.
Certificate generation failed?
- Make sure Docker Desktop is running. You can check by looking for the Docker whale icon in your menu bar.
- If you see a permission denied error, try closing and reopening your terminal.
- If the image pull times out, check your internet connection and try running the command again.
- help me fix the Wazuh certificate generation command failing
Start the Wazuh stack
With certificates in place, you can now start all three Wazuh containers. They will start in the background and take about one to two minutes to fully initialize.
- Start the Wazuh Docker deployment by running this command:
docker compose up -d
What does this command do?
- docker compose up reads the docker-compose.yml file and starts all defined services (manager, indexer, dashboard).
- The -d flag runs the containers in detached mode, so they run in the background and you keep your terminal free.
- Wait 1-2 minutes for the containers to initialize. The indexer takes the longest to start up.
- Verify all three containers are running by running this command:
docker compose ps
You should see three services listed with a status of running: wazuh.manager, wazuh.indexer, and wazuh.dashboard.
Containers not all running?
- If a container shows as exited or restarting, wait another minute and run docker compose ps again. The indexer can take up to two minutes to become ready.
- If a container keeps restarting, check its logs with docker compose logs wazuh.indexer (replace with the failing service name).
- Make sure Docker Desktop has at least 4 GB of memory allocated. The Wazuh indexer needs significant memory to run.
- help me troubleshoot Wazuh Docker containers not starting
Access and explore the Wazuh Dashboard
The Wazuh Dashboard is a web interface where you can view security alerts, explore detection rules, and investigate incidents. It runs on HTTPS with a self-signed certificate, so your browser will show a security warning when you first connect.
- Open your web browser and navigate to https://localhost.
- Your browser will display a certificate warning because the dashboard uses a self-signed certificate. Click Advanced, then click Proceed to localhost (unsafe) to continue.
- Enter the username admin and password SecretPassword to log in.
Why the certificate warning?
The Wazuh Dashboard uses a self-signed TLS certificate that your browser does not recognize as trusted. This is completely normal for a local lab environment. In production, you would replace it with a certificate from a trusted Certificate Authority.
After logging in, you should see the Wazuh Dashboard home page with panels showing security information.
- Click on Security Events in the left sidebar to see the events panel. This is where detected threats appear.
- Click on Agents in the left sidebar to see the Agents summary. This shows which systems are being monitored.
- Click on the MITRE ATT&CK section to see how alerts map to known adversary techniques.
What are these dashboard sections?
- Security Events displays all detected threats and anomalies in real time.
- Agents shows monitored endpoints. In this lab, the Wazuh manager monitors itself.
- MITRE ATT&CK maps alerts to the standardized adversary tactics framework that SOC analysts use worldwide.
Take note of the exposed ports for future steps. Port 55000 serves the Wazuh Server API. Port 9200 serves the Indexer API. Port 443 serves the Dashboard over HTTPS.
Your SIEM stack is live and monitoring. Next up, you will generate simulated brute-force attack events and watch Wazuh detect them in real time.
Generate and Detect Security Events
Your SIEM is deployed and running, but a detection engine is only as good as the events it processes. Without security events flowing in, the dashboard stays empty.
In this step, you will simulate a brute-force attack by injecting fake authentication-failure log entries into the Wazuh manager container. Wazuh will detect the pattern and generate alerts that you can inspect in the dashboard.
In this step, get ready to:
- Trigger authentication failure alerts inside the Wazuh manager container.
- Verify alerts appear in the Wazuh Dashboard with MITRE ATT&CK mapping.
Trigger authentication failure alerts
A brute-force attack floods a system with login attempts using invalid credentials. Each failed attempt leaves a trace in the system's authentication log.
The Wazuh manager container monitors /var/log/auth.log by default. You will write simulated sshd failure entries directly into that file to guarantee alert generation.
- Open a shell inside the Wazuh manager container by running this command:
docker exec -it single-node-wazuh.manager-1 bash
What does this command do?
docker exec -it opens an interactive terminal session inside a running container. You are now operating inside the Wazuh manager, where the log analysis engine runs.
- Simulate 10 failed SSH login attempts from a fake attacker IP by running this command inside the container:
for i in $(seq 1 10); do
echo "$(date '+%b %d %H:%M:%S') wazuh sshd[2847$i]: Invalid user attacker from 192.168.1.100 port 4832$i" >> /var/log/auth.log
sleep 1
done
What does this code do?
The loop writes 10 log entries into /var/log/auth.log, one per second. Each entry follows the standard syslog format for an sshd invalid-user attempt.
Wazuh rule 5710 matches the pattern Invalid user in sshd logs. When the manager detects these entries, it generates alerts with MITRE ATT&CK technique T1110 (Brute Force) mapped automatically.
- Wait about 10 seconds for the loop to finish.
- Exit the container shell by running:
exit
Wazuh processes new log entries within seconds. Your alerts should now be indexed and visible in the dashboard.
Container name not found?
If you see an error that the container does not exist, confirm the correct name by running docker compose ps from the wazuh-docker/single-node/ directory. The manager container name should appear in the output.
help me find my Wazuh manager container name
Verify alerts in the Wazuh Dashboard
The simulated log entries have been processed by the Wazuh analysis engine. Now you will confirm that real alerts appeared in the dashboard.
- Switch back to your browser with the Wazuh Dashboard at https://localhost.
- Click the hamburger menu in the top left corner.
- Select Security Events from the left sidebar (you may find it under Threat Hunting > Events depending on your dashboard version).
- Look for alerts with Rule IDs 5710, 5503, or 5551 in the events table.
- Click on one of the alerts to expand its full details.
Inside the alert details, you should see the timestamp, source IP (192.168.1.100), rule description, and the MITRE ATT&CK technique T1110 (Brute Force).
What is MITRE ATT&CK?
MITRE ATT&CK is a knowledge base of adversary tactics and techniques observed in real-world attacks. Wazuh maps its detection rules to specific ATT&CK technique IDs so analysts can quickly understand what type of attack is happening.
T1110 (Brute Force) means an adversary is attempting to gain access by systematically trying many passwords or usernames. Seeing this technique on an alert tells you exactly what class of attack triggered it.
Not seeing any alerts?
Make sure the time filter in the top right of the dashboard is set to at least the last 15 minutes. If the time range is too narrow, recent alerts may not appear.
If alerts still do not appear, verify the log entries were written successfully by running:
docker exec single-node-wazuh.manager-1 tail -5 /var/log/auth.log
You should see lines containing Invalid user attacker. If the file is empty, re-run the log injection commands from the previous substep.
help me troubleshoot missing Wazuh alerts
Your SIEM detected the simulated brute-force attack in real time. Next up, you will write a Python script to query these alerts programmatically and produce a structured attack analysis.
Analyze Alerts with Python
You have brute-force alerts sitting in the Wazuh Indexer. A SOC analyst's next move is to pull those alerts out programmatically and summarize the attack patterns.
In this step, you will write a Python script that authenticates to the Wazuh Indexer API, queries for recent alerts, and prints a structured analysis report to the terminal.
In this step, get ready to:
- Write a Python script that authenticates and queries the Wazuh Indexer API.
- Parse alert data and summarize attack patterns by rule, IP, and severity.
- Add MITRE ATT&CK context and a threat assessment to the report output.
Write a Python script to authenticate and query the Wazuh Indexer API
The Wazuh Indexer exposes a REST API at port 9200 that lets you search alert data the same way the dashboard does. Your script will use Python's requests library to POST a search query to this API.
- Create a new file called analyze_alerts.py in the wazuh-docker/single-node/ directory by running:
touch analyze_alerts.py
- Open analyze_alerts.py in your text editor and paste the following setup code:
#!/usr/bin/env python3
import json
import requests
import urllib3
from collections import Counter
from datetime import datetime
from requests.auth import HTTPBasicAuth
# Disable insecure HTTPS warnings (self-signed certificates)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Configuration
INDEXER_URL = "https://localhost:9200"
INDEX = "wazuh-alerts*"
USERNAME = "admin"
PASSWORD = "SecretPassword"
# MITRE ATT&CK reference descriptions
MITRE_DESCRIPTIONS = {
"T1110": "Brute Force - Adversaries may use brute force techniques to gain access to accounts when passwords are unknown.",
"T1078": "Valid Accounts - Adversaries may obtain and abuse credentials of existing accounts.",
"T1021": "Remote Services - Adversaries may use valid accounts to log into remote services.",
}
What does this code do?
The imports bring in requests for HTTP calls, urllib3 to suppress SSL warnings (since Wazuh uses self-signed certificates), and Counter from Python's standard library for tallying alert counts.
The configuration block points to the Indexer API at https://localhost:9200 and uses the same admin/SecretPassword credentials you used for the dashboard. The MITRE_DESCRIPTIONS dictionary maps technique IDs to human-readable explanations for the final report.
- Below the configuration, add the search_alerts() function that queries the Indexer API:
def search_alerts():
"""Query the Wazuh indexer for authentication-failure alerts."""
endpoint = f"{INDEXER_URL}/{INDEX}/_search"
headers = {"Content-Type": "application/json"}
payload = {
"size": 100,
"query": {
"bool": {
"must": [
{"range": {"timestamp": {"gte": "now-1h"}}},
{"range": {"rule.level": {"gte": 5}}}
]
}
},
"sort": [{"timestamp": {"order": "desc"}}]
}
response = requests.post(
endpoint,
headers=headers,
auth=HTTPBasicAuth(USERNAME, PASSWORD),
data=json.dumps(payload),
verify=False
)
response.raise_for_status()
return response.json()
What does this function do?
It sends a POST request to the /wazuh-alerts*/_search endpoint with a JSON payload. The query filters for alerts from the last hour that have a rule level of 5 or higher, sorted newest first.
The verify=False parameter tells requests to skip SSL certificate verification. This is necessary because the Wazuh Docker deployment uses self-signed certificates.
- Save analyze_alerts.py.
You will not see output yet. The next section adds the function that parses and prints the results.
Parse and summarize attack patterns
The raw JSON from the Indexer contains nested fields. Your analysis function will extract the fields that matter for incident response and aggregate them into a readable summary.
- Below the search_alerts() function, add the analyze_results() function:
def analyze_results(results):
"""Parse and summarize the alert data."""
hits = results.get("hits", {}).get("hits", [])
if not hits:
print("\nNo alerts found in the last hour.")
print("Tip: Make sure you have generated security events (Step 3).")
return
# Extract alert details
alerts = []
for hit in hits:
source = hit.get("_source", {})
alert = {
"timestamp": source.get("timestamp", "N/A"),
"rule_id": source.get("rule", {}).get("id", "N/A"),
"rule_level": source.get("rule", {}).get("level", 0),
"rule_description": source.get("rule", {}).get("description", "N/A"),
"source_ip": source.get("data", {}).get("srcip", "N/A"),
"mitre_ids": [t.get("id", "") for t in source.get("rule", {}).get("mitre", {}).get("technique", [])],
}
alerts.append(alert)
# Print report header
print("\n" + "=" * 60)
print("WAZUH ALERT ANALYSIS REPORT")
print("=" * 60)
print(f"\nTotal Alerts Analyzed: {len(alerts)}")
# Time window
timestamps = [a["timestamp"] for a in alerts if a["timestamp"] != "N/A"]
if timestamps:
print(f"Time Window: {timestamps[-1]} to {timestamps[0]}")
# Alerts by Rule ID
rule_counts = Counter(f"{a['rule_id']} - {a['rule_description']}" for a in alerts)
print("\n--- Alerts by Rule ---")
for rule, count in rule_counts.most_common(10):
print(f" [{count:3d}] {rule}")
# Source IPs
ip_counts = Counter(a["source_ip"] for a in alerts if a["source_ip"] != "N/A")
if ip_counts:
print("\n--- Source IPs ---")
for ip, count in ip_counts.most_common(10):
print(f" [{count:3d}] {ip}")
# Severity breakdown
severity_counts = {"High (11-15)": 0, "Medium (6-10)": 0, "Low (0-5)": 0}
for a in alerts:
level = a["rule_level"]
if level >= 11:
severity_counts["High (11-15)"] += 1
elif level >= 6:
severity_counts["Medium (6-10)"] += 1
else:
severity_counts["Low (0-5)"] += 1
print("\n--- Severity Breakdown ---")
for severity, count in severity_counts.items():
print(f" {severity}: {count}")
# MITRE ATT&CK context
all_mitre = set()
for a in alerts:
all_mitre.update(a["mitre_ids"])
all_mitre.discard("")
if all_mitre:
print("\n--- MITRE ATT&CK Techniques Observed ---")
for technique_id in sorted(all_mitre):
description = MITRE_DESCRIPTIONS.get(technique_id, "No description available.")
print(f" {technique_id}: {description}")
# Threat assessment
print("\n--- Threat Assessment ---")
print(f" Total alerts: {len(alerts)}")
print(f" Unique source IPs: {len(ip_counts)}")
print(f" MITRE techniques: {len(all_mitre)}")
print(f" Recommended action: Investigate source IPs, verify no successful logins,")
print(f" consider blocking repeat offenders at the firewall level.")
print("\n" + "=" * 60)
What does this function do?
It walks through each alert hit and extracts the timestamp, rule ID, severity level, description, source IP, and any MITRE ATT&CK technique IDs.
Then it aggregates the data into four sections. Alerts by Rule uses a Counter to rank which rules fired most. Source IPs identifies where attacks originated. Severity Breakdown categorizes alerts into High (11-15), Medium (6-10), and Low (0-5) bands.
The final sections map any detected MITRE technique IDs to their descriptions and output a Threat Assessment with a recommended response action.
- Save analyze_alerts.py.
You will not see output yet. The next section adds the entry point that ties everything together.
Add MITRE ATT&CK context and run the script
The MITRE_DESCRIPTIONS dictionary and the MITRE section inside analyze_results() map raw technique IDs like T1110 to plain-English explanations. This turns cryptic IDs into actionable intelligence a SOC analyst can include in an incident ticket.
- At the very bottom of analyze_alerts.py, add the main entry point that calls both functions:
if __name__ == "__main__":
print("Querying Wazuh Indexer for security alerts...")
try:
results = search_alerts()
analyze_results(results)
except requests.exceptions.ConnectionError:
print("Error: Cannot connect to Wazuh Indexer at https://localhost:9200")
print("Make sure the Wazuh Docker stack is running (docker compose ps)")
except Exception as e:
print(f"Error: {e}")
What does this block do?
The if __name__ == "__main__" guard ensures this code only runs when you execute the file directly. It calls search_alerts() to fetch data, then passes the results to analyze_results() for printing.
The try/except blocks catch a connection failure (if Docker is down) and any other unexpected error, printing a helpful message instead of a raw traceback.
- Save analyze_alerts.py.
- Run the script by executing:
python3 analyze_alerts.py
You should see a formatted report in your terminal that starts with WAZUH ALERT ANALYSIS REPORT and includes sections for alerts by rule, source IPs, severity breakdown, MITRE ATT&CK techniques, and a threat assessment.
Seeing a connection error?
- Make sure the Wazuh Docker stack is still running. Switch back to the wazuh-docker/single-node/ directory and run docker compose ps to check all three containers show as running.
- If you see "No alerts found in the last hour", the time window may have elapsed since you generated events in Step 3. Re-run the alert generation commands from the previous step, then run the script again.
- If you see a certificate error, confirm that verify=False is set in the requests.post() call and that the urllib3.disable_warnings line is at the top of the file.
Help me debug my analyze_alerts.py script
✔️ Awesome, I've got everything!
Great. Double check you have saved analyze_alerts.py and that the report printed successfully.
ⓧ I'd like to double check the full code
Here is the complete analyze_alerts.py file:
#!/usr/bin/env python3
import json
import requests
import urllib3
from collections import Counter
from datetime import datetime
from requests.auth import HTTPBasicAuth
# Disable insecure HTTPS warnings (self-signed certificates)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Configuration
INDEXER_URL = "https://localhost:9200"
INDEX = "wazuh-alerts*"
USERNAME = "admin"
PASSWORD = "SecretPassword"
# MITRE ATT&CK reference descriptions
MITRE_DESCRIPTIONS = {
"T1110": "Brute Force - Adversaries may use brute force techniques to gain access to accounts when passwords are unknown.",
"T1078": "Valid Accounts - Adversaries may obtain and abuse credentials of existing accounts.",
"T1021": "Remote Services - Adversaries may use valid accounts to log into remote services.",
}
def search_alerts():
"""Query the Wazuh indexer for authentication-failure alerts."""
endpoint = f"{INDEXER_URL}/{INDEX}/_search"
headers = {"Content-Type": "application/json"}
payload = {
"size": 100,
"query": {
"bool": {
"must": [
{"range": {"timestamp": {"gte": "now-1h"}}},
{"range": {"rule.level": {"gte": 5}}}
]
}
},
"sort": [{"timestamp": {"order": "desc"}}]
}
response = requests.post(
endpoint,
headers=headers,
auth=HTTPBasicAuth(USERNAME, PASSWORD),
data=json.dumps(payload),
verify=False
)
response.raise_for_status()
return response.json()
def analyze_results(results):
"""Parse and summarize the alert data."""
hits = results.get("hits", {}).get("hits", [])
if not hits:
print("\nNo alerts found in the last hour.")
print("Tip: Make sure you have generated security events (Step 3).")
return
alerts = []
for hit in hits:
source = hit.get("_source", {})
alert = {
"timestamp": source.get("timestamp", "N/A"),
"rule_id": source.get("rule", {}).get("id", "N/A"),
"rule_level": source.get("rule", {}).get("level", 0),
"rule_description": source.get("rule", {}).get("description", "N/A"),
"source_ip": source.get("data", {}).get("srcip", "N/A"),
"mitre_ids": [t.get("id", "") for t in source.get("rule", {}).get("mitre", {}).get("technique", [])],
}
alerts.append(alert)
print("\n" + "=" * 60)
print("WAZUH ALERT ANALYSIS REPORT")
print("=" * 60)
print(f"\nTotal Alerts Analyzed: {len(alerts)}")
timestamps = [a["timestamp"] for a in alerts if a["timestamp"] != "N/A"]
if timestamps:
print(f"Time Window: {timestamps[-1]} to {timestamps[0]}")
rule_counts = Counter(f"{a['rule_id']} - {a['rule_description']}" for a in alerts)
print("\n--- Alerts by Rule ---")
for rule, count in rule_counts.most_common(10):
print(f" [{count:3d}] {rule}")
ip_counts = Counter(a["source_ip"] for a in alerts if a["source_ip"] != "N/A")
if ip_counts:
print("\n--- Source IPs ---")
for ip, count in ip_counts.most_common(10):
print(f" [{count:3d}] {ip}")
severity_counts = {"High (11-15)": 0, "Medium (6-10)": 0, "Low (0-5)": 0}
for a in alerts:
level = a["rule_level"]
if level >= 11:
severity_counts["High (11-15)"] += 1
elif level >= 6:
severity_counts["Medium (6-10)"] += 1
else:
severity_counts["Low (0-5)"] += 1
print("\n--- Severity Breakdown ---")
for severity, count in severity_counts.items():
print(f" {severity}: {count}")
all_mitre = set()
for a in alerts:
all_mitre.update(a["mitre_ids"])
all_mitre.discard("")
if all_mitre:
print("\n--- MITRE ATT&CK Techniques Observed ---")
for technique_id in sorted(all_mitre):
description = MITRE_DESCRIPTIONS.get(technique_id, "No description available.")
print(f" {technique_id}: {description}")
print("\n--- Threat Assessment ---")
print(f" Total alerts: {len(alerts)}")
print(f" Unique source IPs: {len(ip_counts)}")
print(f" MITRE techniques: {len(all_mitre)}")
print(f" Recommended action: Investigate source IPs, verify no successful logins,")
print(f" consider blocking repeat offenders at the firewall level.")
print("\n" + "=" * 60)
if __name__ == "__main__":
print("Querying Wazuh Indexer for security alerts...")
try:
results = search_alerts()
analyze_results(results)
except requests.exceptions.ConnectionError:
print("Error: Cannot connect to Wazuh Indexer at https://localhost:9200")
print("Make sure the Wazuh Docker stack is running (docker compose ps)")
except Exception as e:
print(f"Error: {e}")
Your Python script can now pull alerts from the SIEM and produce a structured analysis in seconds. Next up, you will build a PowerShell incident response script that triages these same alerts by severity and generates a formal report.
Create a PowerShell Incident Response Script
Your Python script gives you a quick summary of what happened. Now you need a structured incident report that a SOC team can act on.
In a real Security Operations Center, analysts triage alerts by severity and produce formal reports. You will build a PowerShell script that queries the same Wazuh Indexer API, categorizes alerts into severity tiers, and outputs a formatted incident response report.
In this step, get ready to:
- Write a PowerShell script that authenticates to the Wazuh Indexer API and retrieves alerts.
- Triage alerts by severity level into high, medium, and low categories.
- Generate a structured incident response report and save it to a file.
Write the PowerShell authentication and data retrieval
The Wazuh Indexer uses self-signed certificates. Before the script can make API calls, it needs to bypass SSL validation and set up basic authentication headers.
- Create a new file called incident_report.ps1 in the same directory as your analyze_alerts.py file.
- Add the script configuration and SSL bypass by pasting in this code:
#!/usr/bin/env pwsh
# Wazuh Incident Response Report Generator
# Queries the Wazuh Indexer API and generates a structured incident report
# Configuration
$IndexerUrl = "https://localhost:9200"
$Index = "wazuh-alerts*"
$Username = "admin"
$Password = "SecretPassword"
$ReportDate = Get-Date -Format "yyyyMMdd"
$ReportFile = "incident_report_$ReportDate.txt"
# Skip SSL certificate validation (self-signed certs in lab)
if (-not ([System.Management.Automation.PSTypeName]'TrustAllCertsPolicy').Type) {
Add-Type @"
using System.Net;
using System.Security.Cryptography.X509Certificates;
public class TrustAllCertsPolicy : ICertificatePolicy {
public bool CheckValidationResult(ServicePoint sp, X509Certificate cert, WebRequest req, int problem) { return true; }
}
"@
}
[System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
What does this code do?
The $ReportFile variable dynamically names the output file with today's date, so each run produces a uniquely named report.
The TrustAllCertsPolicy class is a custom C# type loaded via Add-Type that tells .NET to accept any certificate. The if (-not ...) guard prevents errors if the type was already defined in your session.
Setting SecurityProtocol to TLS 1.2 ensures the connection uses a modern encryption standard.
- Now add the authentication header, search payload, and API call below the SSL bypass code:
# Build authentication header
$base64Auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("${Username}:${Password}"))
$headers = @{
"Authorization" = "Basic $base64Auth"
"Content-Type" = "application/json"
}
# Search payload
$payload = @{
size = 100
query = @{
bool = @{
must = @(
@{ range = @{ timestamp = @{ gte = "now-1h" } } }
@{ range = @{ "rule.level" = @{ gte = 5 } } }
)
}
}
sort = @(
@{ timestamp = @{ order = "desc" } }
)
} | ConvertTo-Json -Depth 10
# Query the Wazuh Indexer
Write-Host "Querying Wazuh Indexer for security alerts..." -ForegroundColor Cyan
try {
$response = Invoke-RestMethod -Uri "$IndexerUrl/$Index/_search" `
-Method Post `
-Headers $headers `
-Body $payload
}
catch {
Write-Host "Error connecting to Wazuh Indexer: $_" -ForegroundColor Red
Write-Host "Make sure the Wazuh Docker stack is running." -ForegroundColor Yellow
exit 1
}
What does this code do?
The credentials are Base64-encoded into a Basic auth header. This is the same approach the Python script uses, just expressed in PowerShell syntax.
The $payload hashtable is identical in logic to the Python version: fetch up to 100 alerts from the last hour with a rule level of 5 or higher, sorted newest first. ConvertTo-Json -Depth 10 serializes nested hashtables correctly.
The try/catch block gracefully handles connection failures (like Docker not running) and exits with a non-zero code.
- Save incident_report.ps1.
Seeing an error about TrustAllCertsPolicy?
If you see a type-not-found error, make sure the Add-Type here-string delimiters (@" and "@) are each on their own line with no leading spaces before "@. PowerShell requires the closing delimiter to start at column 1.
help me fix the TrustAllCertsPolicy error in my PowerShell script
Triage and categorize alerts
SOC analysts group alerts by severity before investigating. High-severity alerts need immediate attention, while low-severity ones can be reviewed in batch.
- Add the triage logic below the try/catch block in incident_report.ps1:
# Parse results
$hits = $response.hits.hits
$totalAlerts = $hits.Count
if ($totalAlerts -eq 0) {
Write-Host "No alerts found in the last hour." -ForegroundColor Yellow
Write-Host "Tip: Make sure you have generated security events (Step 3)."
exit 0
}
# Categorize by severity
$high = @()
$medium = @()
$low = @()
foreach ($hit in $hits) {
$source = $hit._source
$level = [int]$source.rule.level
$alertInfo = @{
Timestamp = $source.timestamp
RuleId = $source.rule.id
Level = $level
Description = $source.rule.description
SourceIP = if ($source.data.srcip) { $source.data.srcip } else { "N/A" }
MitreTech = ($source.rule.mitre.technique | ForEach-Object { $_.id }) -join ", "
}
if ($level -ge 11) { $high += $alertInfo }
elseif ($level -ge 6) { $medium += $alertInfo }
else { $low += $alertInfo }
}
What does this code do?
The script extracts the nested hits.hits array from the API response. If no alerts are found, it exits early with a helpful message.
Each alert is parsed into a hashtable with key fields: timestamp, rule ID, severity level, description, source IP, and MITRE technique. The rule.level field determines severity: levels 11-15 are high, 6-10 are medium, and 0-5 are low.
- Save incident_report.ps1.
Seeing null values in your alert data?
If $totalAlerts is 0, your alerts may have expired from the 1-hour window. Return to Step 3 and re-generate the authentication failure events before running this script.
help me troubleshoot why my PowerShell script finds no alerts in the Wazuh Indexer
Generate a structured incident report
The final section assembles all the categorized data into a formatted report that includes an incident summary, timeline, severity details, and recommended next steps.
- Add the report generation code below the foreach loop in incident_report.ps1:
# Generate report
$report = @"
================================================================
INCIDENT RESPONSE REPORT
================================================================
Generated: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")
Analyst: SOC Analyst (Lab Exercise)
Status: Under Investigation
--- INCIDENT SUMMARY ---
Total Alerts: $totalAlerts
High Severity (11-15): $($high.Count)
Medium Severity (6-10): $($medium.Count)
Low Severity (0-5): $($low.Count)
--- TIMELINE ---
Earliest Alert: $(if ($hits[-1]._source.timestamp) { $hits[-1]._source.timestamp } else { "N/A" })
Latest Alert: $(if ($hits[0]._source.timestamp) { $hits[0]._source.timestamp } else { "N/A" })
--- HIGH SEVERITY ALERTS ---
"@
if ($high.Count -gt 0) {
foreach ($a in $high) {
$report += "`n [$($a.Timestamp)] Rule $($a.RuleId) (Level $($a.Level))"
$report += "`n $($a.Description)"
$report += "`n Source IP: $($a.SourceIP) | MITRE: $($a.MitreTech)"
}
}
else {
$report += "`n None detected."
}
$report += "`n`n--- MEDIUM SEVERITY ALERTS (Top 10) ---"
if ($medium.Count -gt 0) {
$topMedium = $medium | Select-Object -First 10
foreach ($a in $topMedium) {
$report += "`n [$($a.Timestamp)] Rule $($a.RuleId) (Level $($a.Level))"
$report += "`n $($a.Description)"
$report += "`n Source IP: $($a.SourceIP) | MITRE: $($a.MitreTech)"
}
}
else {
$report += "`n None detected."
}
What does this code do?
The here-string (@"..."@) builds the report header with dynamic values interpolated directly. The timeline uses the last and first elements of the sorted hits array to show earliest and latest alert timestamps.
High-severity alerts get full detail (timestamp, rule, source IP, MITRE technique). Medium-severity alerts are capped at 10 entries to keep the report concise. If no alerts exist at that severity, the report notes "None detected."
- Add the remaining report sections (source IPs, MITRE techniques, recommendations, and file output) below the medium-severity block:
# Unique IPs
$allIPs = $hits | ForEach-Object { $_._source.data.srcip } | Where-Object { $_ } | Sort-Object -Unique
$report += "`n`n--- AFFECTED SYSTEMS / SOURCE IPs ---"
if ($allIPs.Count -gt 0) {
foreach ($ip in $allIPs) {
$report += "`n - $ip"
}
}
else {
$report += "`n No external source IPs identified."
}
# MITRE techniques
$allMitre = $hits | ForEach-Object { $_._source.rule.mitre.technique } |
ForEach-Object { $_.id } | Where-Object { $_ } | Sort-Object -Unique
$report += "`n`n--- MITRE ATT&CK TECHNIQUES ---"
if ($allMitre.Count -gt 0) {
foreach ($t in $allMitre) {
$report += "`n - $t"
}
}
else {
$report += "`n No MITRE mappings found."
}
$report += @"
`n--- RECOMMENDED NEXT STEPS ---
1. CONTAINMENT: Block identified source IPs at the network perimeter
2. INVESTIGATION: Check for any successful authentications from flagged IPs
3. REMEDIATION: Enforce account lockout policies and MFA
4. RECOVERY: Reset credentials for any potentially compromised accounts
5. LESSONS LEARNED: Update detection rules based on observed patterns
================================================================
END OF REPORT
================================================================
"@
# Output and save
Write-Host $report
$report | Out-File -FilePath $ReportFile -Encoding UTF8
Write-Host "`nReport saved to: $ReportFile" -ForegroundColor Green
What does this code do?
The source IP section pipes all hits through ForEach-Object to extract IPs, filters out nulls with Where-Object { $_ }, and deduplicates with Sort-Object -Unique.
The recommended next steps follow a standard incident response framework: containment, investigation, remediation, recovery, and lessons learned. These are the five phases a SOC analyst would follow after detecting a brute-force attack.
Finally, the report is printed to the terminal and saved to a timestamped file using Out-File.
- Save incident_report.ps1.
- Run the script by executing this command in your terminal:
pwsh incident_report.ps1
- You should see the full incident report printed in your terminal, followed by a green message confirming the file was saved (e.g. Report saved to: incident_report_20260627.txt).
- Confirm the report file exists by running:
ls incident_report_*.txt
- You should see incident_report_YYYYMMDD.txt listed with today's date.
Script runs but shows 0 alerts?
The query searches for alerts from the last hour. If more than an hour has passed since you generated events in Step 3, re-run the event generation commands and try again.
Also confirm your Docker containers are still running with docker compose ps from the wazuh-docker/single-node/ directory.
help me debug why my PowerShell incident report script finds no alerts
✔️ Awesome, I've got everything!
Great. Double check you have saved incident_report.ps1 and confirmed the report file was generated.
ⓧ I'd like to double check the full code
Here is the complete incident_report.ps1 file:
#!/usr/bin/env pwsh
# Wazuh Incident Response Report Generator
# Queries the Wazuh Indexer API and generates a structured incident report
# Configuration
$IndexerUrl = "https://localhost:9200"
$Index = "wazuh-alerts*"
$Username = "admin"
$Password = "SecretPassword"
$ReportDate = Get-Date -Format "yyyyMMdd"
$ReportFile = "incident_report_$ReportDate.txt"
# Skip SSL certificate validation (self-signed certs in lab)
if (-not ([System.Management.Automation.PSTypeName]'TrustAllCertsPolicy').Type) {
Add-Type @"
using System.Net;
using System.Security.Cryptography.X509Certificates;
public class TrustAllCertsPolicy : ICertificatePolicy {
public bool CheckValidationResult(ServicePoint sp, X509Certificate cert, WebRequest req, int problem) { return true; }
}
"@
}
[System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
# Build authentication header
$base64Auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("${Username}:${Password}"))
$headers = @{
"Authorization" = "Basic $base64Auth"
"Content-Type" = "application/json"
}
# Search payload
$payload = @{
size = 100
query = @{
bool = @{
must = @(
@{ range = @{ timestamp = @{ gte = "now-1h" } } }
@{ range = @{ "rule.level" = @{ gte = 5 } } }
)
}
}
sort = @(
@{ timestamp = @{ order = "desc" } }
)
} | ConvertTo-Json -Depth 10
# Query the Wazuh Indexer
Write-Host "Querying Wazuh Indexer for security alerts..." -ForegroundColor Cyan
try {
$response = Invoke-RestMethod -Uri "$IndexerUrl/$Index/_search" `
-Method Post `
-Headers $headers `
-Body $payload
}
catch {
Write-Host "Error connecting to Wazuh Indexer: $_" -ForegroundColor Red
Write-Host "Make sure the Wazuh Docker stack is running." -ForegroundColor Yellow
exit 1
}
# Parse results
$hits = $response.hits.hits
$totalAlerts = $hits.Count
if ($totalAlerts -eq 0) {
Write-Host "No alerts found in the last hour." -ForegroundColor Yellow
Write-Host "Tip: Make sure you have generated security events (Step 3)."
exit 0
}
# Categorize by severity
$high = @()
$medium = @()
$low = @()
foreach ($hit in $hits) {
$source = $hit._source
$level = [int]$source.rule.level
$alertInfo = @{
Timestamp = $source.timestamp
RuleId = $source.rule.id
Level = $level
Description = $source.rule.description
SourceIP = if ($source.data.srcip) { $source.data.srcip } else { "N/A" }
MitreTech = ($source.rule.mitre.technique | ForEach-Object { $_.id }) -join ", "
}
if ($level -ge 11) { $high += $alertInfo }
elseif ($level -ge 6) { $medium += $alertInfo }
else { $low += $alertInfo }
}
# Generate report
$report = @"
================================================================
INCIDENT RESPONSE REPORT
================================================================
Generated: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")
Analyst: SOC Analyst (Lab Exercise)
Status: Under Investigation
--- INCIDENT SUMMARY ---
Total Alerts: $totalAlerts
High Severity (11-15): $($high.Count)
Medium Severity (6-10): $($medium.Count)
Low Severity (0-5): $($low.Count)
--- TIMELINE ---
Earliest Alert: $(if ($hits[-1]._source.timestamp) { $hits[-1]._source.timestamp } else { "N/A" })
Latest Alert: $(if ($hits[0]._source.timestamp) { $hits[0]._source.timestamp } else { "N/A" })
--- HIGH SEVERITY ALERTS ---
"@
if ($high.Count -gt 0) {
foreach ($a in $high) {
$report += "`n [$($a.Timestamp)] Rule $($a.RuleId) (Level $($a.Level))"
$report += "`n $($a.Description)"
$report += "`n Source IP: $($a.SourceIP) | MITRE: $($a.MitreTech)"
}
}
else {
$report += "`n None detected."
}
$report += "`n`n--- MEDIUM SEVERITY ALERTS (Top 10) ---"
if ($medium.Count -gt 0) {
$topMedium = $medium | Select-Object -First 10
foreach ($a in $topMedium) {
$report += "`n [$($a.Timestamp)] Rule $($a.RuleId) (Level $($a.Level))"
$report += "`n $($a.Description)"
$report += "`n Source IP: $($a.SourceIP) | MITRE: $($a.MitreTech)"
}
}
else {
$report += "`n None detected."
}
# Unique IPs
$allIPs = $hits | ForEach-Object { $_._source.data.srcip } | Where-Object { $_ } | Sort-Object -Unique
$report += "`n`n--- AFFECTED SYSTEMS / SOURCE IPs ---"
if ($allIPs.Count -gt 0) {
foreach ($ip in $allIPs) {
$report += "`n - $ip"
}
}
else {
$report += "`n No external source IPs identified."
}
# MITRE techniques
$allMitre = $hits | ForEach-Object { $_._source.rule.mitre.technique } |
ForEach-Object { $_.id } | Where-Object { $_ } | Sort-Object -Unique
$report += "`n`n--- MITRE ATT&CK TECHNIQUES ---"
if ($allMitre.Count -gt 0) {
foreach ($t in $allMitre) {
$report += "`n - $t"
}
}
else {
$report += "`n No MITRE mappings found."
}
$report += @"
`n--- RECOMMENDED NEXT STEPS ---
1. CONTAINMENT: Block identified source IPs at the network perimeter
2. INVESTIGATION: Check for any successful authentications from flagged IPs
3. REMEDIATION: Enforce account lockout policies and MFA
4. RECOVERY: Reset credentials for any potentially compromised accounts
5. LESSONS LEARNED: Update detection rules based on observed patterns
================================================================
END OF REPORT
================================================================
"@
# Output and save
Write-Host $report
$report | Out-File -FilePath $ReportFile -Encoding UTF8
Write-Host "`nReport saved to: $ReportFile" -ForegroundColor Green
You now have two complementary analysis tools. Your Python script gives a quick operational summary, and your PowerShell script produces a formal incident report ready for handoff. Next up, you will take on the Secret Mission and write a custom detection rule from scratch.
Secret mission
Write a Custom Wazuh Detection Rule
You have used Wazuh's built-in rules to detect brute-force attacks. Now it is time to create your own detection logic from scratch. In this secret mission, you will write a custom XML rule that fires when 5 or more authentication failures come from the same IP within 60 seconds, mount it into the Wazuh manager, and verify it triggers in the dashboard.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using Docker containers, so there are no ongoing cloud costs. However, the Wazuh stack does consume approximately 3-4 GB of RAM while running. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Three Docker containers (Wazuh manager, Wazuh indexer, Wazuh dashboard).
- Docker volumes storing Wazuh data and certificates.
- Local project files (wazuh-docker/ repository, analyze_alerts.py, incident_report.ps1, custom_rule.xml, generated report).
- PowerShell 7.6 (installed via Homebrew).
- Python requests library.
Keep everything running
No action needed. Choose this if you are still actively experimenting with Wazuh or plan to continue developing your detection rules.
- Your Docker containers will continue running and consuming approximately 3-4 GB of RAM.
- PowerShell and the requests library remain installed on your machine.
Pause - I'll come back to this later
Shut down running containers to free up RAM. Your data and configuration stay on disk so you can resume later.
- Navigate to the wazuh-docker/single-node/ directory in your terminal.
- Stop the containers by running:
docker compose stop
- When you want to resume, navigate back to wazuh-docker/single-node/ and run docker compose up -d to start everything again.
Delete - I don't want to use this again
Remove all project resources completely. This frees up RAM, disk space, and removes all installed tools from this project.
- Navigate to the wazuh-docker/single-node/ directory in your terminal.
- Stop all containers and remove their volumes by running:
docker compose down -v
- Navigate back to your Desktop by running:
cd ~/Desktop
- Remove the cloned repository and all project files inside it by running:
rm -rf wazuh-docker
- If you created analyze_alerts.py, incident_report.ps1, or custom_rule.xml outside of the wazuh-docker folder, delete those files from wherever you saved them.
- Uninstall PowerShell by running:
brew uninstall powershell
- Uninstall the Python requests library by running:
pip3 uninstall requests
- Confirm removal by typing y when prompted.
Nice Work!
Nice Work!
You just built a fully operational SOC lab on your local machine. You deployed a production-grade SIEM, simulated a brute-force attack, detected it in real time, and wrote scripts to automate the investigation.
You've learned how to:
- Deploy a Wazuh SIEM stack locally using Docker, generate simulated brute-force events, and observe real-time alert detection with MITRE ATT&CK technique mapping.
- Build a Python script that authenticates to the Wazuh Indexer API and produces a structured attack analysis with severity breakdowns and threat assessments.
- Build a PowerShell incident response script that triages alerts by severity and generates a formal incident report.
- Secret Mission: Created a custom Wazuh detection rule, deployed it into the manager container, and verified it catches your defined attack pattern.
Ready to quiz yourself?