CR-2026-02-25 - Wazuh Credential Rotation (Emergency)
Change Request Summary
| Field | Value |
|---|---|
CR ID |
CR-2026-02-25-002 |
Status |
Emergency |
Priority |
P0 |
Requester |
evanusmodestus |
Date |
2026-02-25 |
Description
EMERGENCY: Rotate Wazuh indexer credentials that were inadvertently exposed in .claude/settings.local.json auto-approve rules.
Exposure vector: Hardcoded password in Claude Code settings file.
Affected secret: indexer-cred in wazuh namespace on k3s-master-01.inside.domusdigitalis.dev.
Credential Architecture
Wazuh uses three separate credentials for different components:
| Secret | Username | Component | Purpose |
|---|---|---|---|
|
admin |
OpenSearch (Indexer) |
Admin API access, cluster management, data queries |
|
kibanaserver |
Dashboard → Indexer |
Backend auth for dashboard to query indexer |
|
wazuh-wui |
Manager API |
UI authentication to Wazuh Manager API |
Only indexer-cred was exposed. The others remain secure.
gopass Restructure (Part of This CR)
Current (flat, incorrect):
v3/domains/d000/k3s/wazuh/ # Single entry with mixed metadata
Target (structured by resource):
v3/domains/d000/k3s/wazuh/indexer # OpenSearch admin
v3/domains/d000/k3s/wazuh/dashboard # Dashboard backend
v3/domains/d000/k3s/wazuh/api # Manager API
Scope
Implementation Plan
Phase 0: Pre-Rotation Access Validation
CRITICAL: Validate current credentials work BEFORE rotation.
# Store current password for comparison
CURRENT_PASS=$(ssh k3s-master-01 "kubectl -n wazuh get secret indexer-cred -o jsonpath='{.data.password}' | base64 -d")
echo "Current password: $CURRENT_PASS"
# TEST 1: Indexer API health (from inside cluster)
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
curl -sk -u admin:$CURRENT_PASS https://localhost:9200/_cluster/health" | \
jq -C 'if .status then "✓ PRE-CHECK: Indexer API accessible (status: \(.status))" else "✗ PRE-CHECK FAILED" end'
# TEST 2: Indexer node info
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
curl -sk -u admin:$CURRENT_PASS https://localhost:9200/_nodes/_local/info" | \
jq -C '.nodes | to_entries[0].value | "✓ PRE-CHECK: Node \(.name) version \(.version)"'
# TEST 3: Dashboard backend can reach indexer (check logs for auth errors)
ssh k3s-master-01 'kubectl -n wazuh logs deployment/wazuh-dashboard --tail=20 | \
grep -E "authentication|unauthorized|error" | head -5 || echo "✓ PRE-CHECK: No auth errors in dashboard logs"'
# TEST 4: Manager can write to indexer
ssh k3s-master-01 'kubectl -n wazuh logs wazuh-manager-master-0 -c wazuh-manager --tail=50 | \
grep -iE "indexer.*error|opensearch.*fail|connection.*refused" | head -5 || echo "✓ PRE-CHECK: No indexer errors in manager logs"'
# Capture pod state for comparison (tee runs locally)
ssh k3s-master-01 'kubectl -n wazuh get pods -o json | \
jq -r ".items[] | \"\(.metadata.name),\(.status.phase),\(.status.containerStatuses[0].restartCount)\""' | \
tee /tmp/wazuh-pods-before.csv
# Display captured state
echo "=== POD STATE BEFORE ==="
column -t -s',' /tmp/wazuh-pods-before.csv
Phase 1: Generate New Credential
|
Resuming after shell restart? If you already stored the password in gopass (Phase 2 done), skip Phase 1 and use:
Continue from where you left off (check completion status of each phase). |
# Generate cryptographically secure 32-character password
NEW_PASS=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
echo "New password: $NEW_PASS"
echo "Length: $(echo -n "$NEW_PASS" | wc -c)"
# Save for later phases (do NOT persist to disk)
export NEW_PASS
Phase 2: Store in gopass (Source of Truth)
CRITICAL: Store credentials in gopass FIRST before updating K8s. If rotation fails mid-process, the new password is safely stored.
# Relocate old flat entry (keep until validation passes)
gopass mv v3/domains/d000/k3s/wazuh v3/domains/d000/k3s/wazuh-pre-rotation 2>/dev/null || true
# Create indexer entry with metadata (heredoc)
cat << EOF | gopass insert -m v3/domains/d000/k3s/wazuh/indexer
$NEW_PASS
---
username: admin
service: wazuh-indexer (OpenSearch)
k8s_secret: indexer-cred
k8s_namespace: wazuh
url: https://wazuh.inside.domusdigitalis.dev:9200
rotated: 2026-02-25
EOF
# Get existing dashboard password from K8s
DASHBOARD_PASS=$(ssh k3s-master-01 "kubectl -n wazuh get secret dashboard-cred -o jsonpath='{.data.password}' | base64 -d")
# Create dashboard entry with metadata
cat << EOF | gopass insert -m v3/domains/d000/k3s/wazuh/dashboard
$DASHBOARD_PASS
---
username: kibanaserver
service: wazuh-dashboard
k8s_secret: dashboard-cred
k8s_namespace: wazuh
url: https://wazuh.inside.domusdigitalis.dev:8443
rotated: 2026-02-25
EOF
# Get existing API password from K8s
API_PASS=$(ssh k3s-master-01 "kubectl -n wazuh get secret wazuh-api-cred -o jsonpath='{.data.password}' | base64 -d")
# Create api entry with metadata
cat << EOF | gopass insert -m v3/domains/d000/k3s/wazuh/api
$API_PASS
---
username: wazuh-wui
service: wazuh-manager API
k8s_secret: wazuh-api-cred
k8s_namespace: wazuh
url: https://wazuh.inside.domusdigitalis.dev:55000
rotated: 2026-02-25
EOF
# Verify structure
gopass ls v3/domains/d000/k3s/wazuh/
v3/domains/d000/k3s/wazuh/api v3/domains/d000/k3s/wazuh/dashboard v3/domains/d000/k3s/wazuh/indexer
# Verify structure gopass ls v3/domains/d000/k3s/wazuh/ v3/domains/d000/k3s/wazuh/ ├── api ├── dashboard └── indexer
Phase 3: Update Kubernetes Secret
# Patch secret with new password (atomic operation)
ssh k3s-master-01 "kubectl -n wazuh patch secret indexer-cred \
--type='json' \
-p='[{\"op\": \"replace\", \"path\": \"/data/password\", \"value\": \"'$(echo -n "$NEW_PASS" | base64)'\"}]'"
# Verify secret updated (resourceVersion should change)
ssh k3s-master-01 'kubectl -n wazuh get secret indexer-cred -o json | \
jq -r "\"Secret updated: resourceVersion=\(.metadata.resourceVersion)\""'
Phase 4: Update OpenSearch Internal User Database
|
CRITICAL STEP - OpenSearch stores password hashes in internal |
|
Why local Python instead of container hash.sh? The container’s WARNING: nor OPENSEARCH_JAVA_HOME nor JAVA_HOME is set /usr/share/wazuh-indexer/plugins/opensearch-security/tools/hash.sh: line 30: java: command not found Generate bcrypt hash locally instead. Both produce identical bcrypt format. |
Step 4.1: Install bcrypt (one-time, idempotent)
# Arch Linux - check before install
python3 -c "import bcrypt" 2>/dev/null && echo "✓ python-bcrypt already installed" || \
{ echo "Installing python-bcrypt..."; sudo pacman -S --noconfirm python-bcrypt; }
# Debian/Ubuntu - check before install
python3 -c "import bcrypt" 2>/dev/null && echo "✓ python3-bcrypt already installed" || \
{ echo "Installing python3-bcrypt..."; sudo apt install -y python3-bcrypt; }
# Fedora/RHEL - check before install
python3 -c "import bcrypt" 2>/dev/null && echo "✓ python3-bcrypt already installed" || \
{ echo "Installing python3-bcrypt..."; sudo dnf install -y python3-bcrypt; }
Step 4.2: Generate bcrypt hash
# Check if NEW_PASS is set, then generate hash
[[ -z "$NEW_PASS" ]] && { echo "✗ NEW_PASS not set. Run: NEW_PASS=\$(gopass show -o v3/domains/d000/k3s/wazuh/indexer)"; exit 1; } || \
{ BCRYPT_HASH=$(python3 -c "import bcrypt; print(bcrypt.hashpw(b'$NEW_PASS', bcrypt.gensalt(rounds=12)).decode())"); \
echo "✓ Hash generated: $BCRYPT_HASH"; }
✓ Hash generated: $2b$12$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Step 4.3: Backup internal_users.yml
|
|
# Check if backup exists, create only if missing
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
sh -c 'test -f /usr/share/wazuh-indexer/config/opensearch-security/internal_users.yml.bak && \
echo \"✓ Backup already exists, skipping\" || \
{ cp /usr/share/wazuh-indexer/config/opensearch-security/internal_users.yml \
/usr/share/wazuh-indexer/config/opensearch-security/internal_users.yml.bak && \
echo \"✓ Backup created\"; }'"
Step 4.4: Update admin hash in internal_users.yml
|
Container limitations:
Solution: Use |
# Verify BCRYPT_HASH is set
[[ -n "$BCRYPT_HASH" ]] && echo "✓ BCRYPT_HASH is set: ${BCRYPT_HASH:0:20}..." || \
{ echo "✗ BCRYPT_HASH not set. Run Step 4.2 first."; exit 1; }
# Step 1: Read file from container to local workstation
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
cat /usr/share/wazuh-indexer/config/opensearch-security/internal_users.yml" > /tmp/internal_users.yml && \
echo "✓ File read to local /tmp/"
|
Why awk instead of sed? Bcrypt hashes contain |
# Step 2: Edit locally with awk (handles $ in bcrypt hash)
# >| forces overwrite (zsh noclobber protection)
awk -v hash="$BCRYPT_HASH" '
/^admin:/ {found=1}
found && /hash:/ {sub(/hash:.*/, "hash: \"" hash "\""); found=0}
{print}
' /tmp/internal_users.yml >| /tmp/internal_users.yml.new && \
mv /tmp/internal_users.yml.new /tmp/internal_users.yml && \
echo "✓ Hash updated locally"
# Step 3: Write to /tmp inside container (config dir is read-only)
ssh k3s-master-01 "kubectl -n wazuh exec -i wazuh-indexer-0 -c wazuh-indexer -- \
sh -c 'cat > /tmp/internal_users.yml'" < /tmp/internal_users.yml && \
echo "✓ File written to container /tmp/"
|
The |
# Step 4: Cleanup local temp file
rm /tmp/internal_users.yml && echo "✓ Local temp file removed"
Step 4.5: Verify the change
# Extract admin's hash from container /tmp file (grep -A1 = 1 line after match)
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
grep -A1 '^admin:' /tmp/internal_users.yml" | grep hash
# Compare visually with your hash
echo "Expected hash: $BCRYPT_HASH"
# Automated verification
CURRENT_HASH=$(ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
grep -A1 '^admin:' /tmp/internal_users.yml" | \
awk '/hash:/ {gsub(/[" ]/, "", $2); print $2}')
[[ "$CURRENT_HASH" == "$BCRYPT_HASH" ]] && echo "✓ Hash verified - matches expected" || \
echo "✗ Hash mismatch! File: $CURRENT_HASH"
Step 4.6: Push to security index
# Run securityadmin.sh from /tmp (config dir is read-only)
# JAVA_HOME required - container lacks 'which' command
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
env JAVA_HOME=/usr/share/wazuh-indexer/jdk \
/usr/share/wazuh-indexer/plugins/opensearch-security/tools/securityadmin.sh \
-f /tmp/internal_users.yml \
-t internalusers \
-icl -nhnv \
-cacert /usr/share/wazuh-indexer/config/certs/root-ca.pem \
-cert /usr/share/wazuh-indexer/config/certs/admin.pem \
-key /usr/share/wazuh-indexer/config/certs/admin-key.pem" 2>&1 | \
tee /tmp/securityadmin-output.txt | \
awk '/Done with success/ {found=1} END {print found ? "✓ Security index updated successfully" : "✗ securityadmin.sh failed - check /tmp/securityadmin-output.txt"}'
Will connect to localhost:9200 ... done Loaded config ... Done with success ✓ Security index updated successfully
# Cleanup: Remove temp file from container (security hygiene)
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
rm -f /tmp/internal_users.yml" && echo "✓ Container temp file removed"
Step 4.7: Verify new password works BEFORE restart
# Test NEW password works (CRITICAL checkpoint)
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
curl -sk -u admin:$NEW_PASS https://localhost:9200/_cluster/health" | jq -C '.'
Output verified 2026-02-25 15:00
{
"cluster_name": "wazuh",
"status": "green",
"timed_out": false,
"number_of_nodes": 1,
"number_of_data_nodes": 1,
"discovered_master": true,
"discovered_cluster_manager": true,
"active_primary_shards": 33,
"active_shards": 33,
"relocating_shards": 0,
"initializing_shards": 0,
"unassigned_shards": 0,
"delayed_unassigned_shards": 0,
"number_of_pending_tasks": 0,
"number_of_in_flight_fetch": 0,
"task_max_waiting_in_queue_millis": 0,
"active_shards_percent_as_number": 100.0
}
Or collapsible:
Output verified 2026-02-25 16:XX
{
"cluster_name": "wazuh",
"status": "green",
"timed_out": false,
"number_of_nodes": 1,
"number_of_data_nodes": 1,
"discovered_master": true,
"discovered_cluster_manager": true,
"active_primary_shards": 33,
"active_shards": 33,
"relocating_shards": 0,
"initializing_shards": 0,
"unassigned_shards": 0,
"delayed_unassigned_shards": 0,
"number_of_pending_tasks": 0,
"number_of_in_flight_fetch": 0,
"task_max_waiting_in_queue_millis": 0,
"active_shards_percent_as_number": 100.0
}
# Verify status is green/yellow (not error)
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
curl -sk -u admin:$NEW_PASS https://localhost:9200/_cluster/health" | \
jq -e '.status' && echo "✓ NEW password works" || echo "✗ NEW password REJECTED"
Output verified 2026-02-25 15:12
{
"cluster_name": "wazuh",
"status": "green",
"timed_out": false,
"number_of_nodes": 1,
"number_of_data_nodes": 1,
"discovered_master": true,
"discovered_cluster_manager": true,
"active_primary_shards": 33,
"active_shards": 33,
"relocating_shards": 0,
"initializing_shards": 0,
"unassigned_shards": 0,
"delayed_unassigned_shards": 0,
"number_of_pending_tasks": 0,
"number_of_in_flight_fetch": 0,
"task_max_waiting_in_queue_millis": 0,
"active_shards_percent_as_number": 100.0
}
# Verify OLD password is now rejected (security validation)
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
curl -sk -u admin:$CURRENT_PASS https://localhost:9200/_cluster/health" 2>&1 | \
grep -qE "Unauthorized|401" && \
echo "✓ OLD password rejected - rotation successful" || \
echo "⚠ OLD password still works - securityadmin.sh may not have applied"
Step 4.8: Update Kubernetes Secret (CRITICAL)
|
Why this step matters: The indexer’s internal database now has the new password (Phase 4), but other pods (manager, dashboard) read credentials from the |
# Patch the secret with new password (encode + patch in one step)
NEW_PASS_B64=$(echo -n "$NEW_PASS" | base64)
ssh k3s-master-01 "kubectl -n wazuh patch secret indexer-cred -p '{\"data\":{\"password\":\"$NEW_PASS_B64\"}}'"
secret/indexer-cred patched
# Verify secret updated (decode to confirm)
ssh k3s-master-01 "kubectl -n wazuh get secret indexer-cred -o jsonpath='{.data.password}'" | base64 -d | \
awk -v new="$NEW_PASS" '{print ($0 == new) ? "✓ Secret updated correctly" : "✗ Secret mismatch!"}'
Step 4.9: Verify StatefulSet Env Var References
|
Why this step matters: The manager pod reads |
# Check if INDEXER_PASSWORD has valueFrom reference
ssh k3s-master-01 "kubectl -n wazuh get statefulset wazuh-manager-master -o jsonpath='{.spec.template.spec.containers[0].env}'" | jq '.[] | select(.name == "INDEXER_PASSWORD")'
{
"name": "INDEXER_PASSWORD",
"valueFrom": {
"secretKeyRef": {
"key": "password",
"name": "indexer-cred"
}
}
}
{
"name": "INDEXER_PASSWORD"
}
If valueFrom is missing, patch the statefulset:
# Fix missing valueFrom reference
ssh k3s-master-01 'kubectl -n wazuh patch statefulset wazuh-manager-master --type=json -p '\''[{"op":"replace","path":"/spec/template/spec/containers/0/env/2","value":{"name":"INDEXER_PASSWORD","valueFrom":{"secretKeyRef":{"key":"password","name":"indexer-cred"}}}}]'\'''
statefulset.apps/wazuh-manager-master patched
Also check manager worker:
# Check manager worker
ssh k3s-master-01 "kubectl -n wazuh get statefulset wazuh-manager-worker -o jsonpath='{.spec.template.spec.containers[0].env}'" | jq '.[] | select(.name == "INDEXER_PASSWORD")'
If missing, patch it too:
ssh k3s-master-01 'kubectl -n wazuh patch statefulset wazuh-manager-worker --type=json -p '\''[{"op":"replace","path":"/spec/template/spec/containers/0/env/2","value":{"name":"INDEXER_PASSWORD","valueFrom":{"secretKeyRef":{"key":"password","name":"indexer-cred"}}}}]'\'''
Phase 5: Restart Pods (Dependency Order)
|
After Step 4.8: You MUST restart pods to pick up the updated secret, even if they were recently restarted. The "skip if recently restarted" check only applies if you’re resuming after a failed restart attempt. |
|
Resuming after failed restart? Check pod age first:
If pods were restarted <10 minutes ago, skip to Phase 6. |
Output verified 2026-02-25 15:17
wazuh-dashboard-69dd56df9d-br88g 3134m ago
wazuh-indexer-0 252m ago
wazuh-manager-master-0 2577m ago
wazuh-manager-worker-0 2684m ago
# 1. Indexer first (database layer) - check if recently restarted
POD_AGE=$(ssh k3s-master-01 "kubectl -n wazuh get pod wazuh-indexer-0 -o jsonpath='{.metadata.creationTimestamp}'" | \
xargs -I{} date -d {} +%s)
NOW=$(date +%s)
AGE_MIN=$(( (NOW - POD_AGE) / 60 ))
[[ $AGE_MIN -lt 10 ]] && echo "✓ Indexer restarted ${AGE_MIN}m ago, skipping" || \
{ echo "Restarting indexer (last restart: ${AGE_MIN}m ago)..."; \
ssh k3s-master-01 "kubectl -n wazuh rollout restart statefulset wazuh-indexer && \
kubectl -n wazuh rollout status statefulset wazuh-indexer --timeout=180s"; }
❯ # Verify secret updated (resourceVersion should change) ssh k3s-master-01 'kubectl -n wazuh get secret indexer-cred -o json | \ jq -r "\"Secret updated: resourceVersion=\(.metadata.resourceVersion)\""' Secret updated: resourceVersion=224900 ❯ # 1. Indexer first (database layer) - must be healthy before others ssh k3s-master-01 "kubectl -n wazuh rollout restart statefulset wazuh-indexer && \ kubectl -n wazuh rollout status statefulset wazuh-indexer --timeout=180s" statefulset.apps/wazuh-indexer restarted Waiting for partitioned roll out to finish: 0 out of 1 new pods have been updated... Waiting for 1 pods to be ready... Waiting for 1 pods to be ready... Waiting for 1 pods to be ready... partitioned roll out complete: 1 new pods have been updated...
for i in {1..30}; do
if ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- curl -sk -u admin:$NEW_PASS https://localhost:9200/_cluster/health" 2>/dev/null | jq -e '.status' &>/dev/null; then
echo "✓ Indexer API ready after $i attempts"
break
fi
echo "Waiting for indexer API... ($i/30)"
sleep 5
done
# 3. Manager master (connects to indexer) - with health check
ssh k3s-master-01 "kubectl -n wazuh rollout restart statefulset wazuh-manager-master && \
kubectl -n wazuh rollout status statefulset wazuh-manager-master --timeout=120s" && \
echo "✓ Manager master restarted" || echo "✗ Manager master restart failed"
# 4. Manager worker - with health check
ssh k3s-master-01 "kubectl -n wazuh rollout restart statefulset wazuh-manager-worker && \
kubectl -n wazuh rollout status statefulset wazuh-manager-worker --timeout=120s" && \
echo "✓ Manager worker restarted" || echo "✗ Manager worker restart failed"
# 5. Dashboard last (UI layer) - with health check
ssh k3s-master-01 "kubectl -n wazuh rollout restart deployment wazuh-dashboard && \
kubectl -n wazuh rollout status deployment wazuh-dashboard --timeout=120s" && \
echo "✓ Dashboard restarted" || echo "✗ Dashboard restart failed"
# 6. Final pod status check - all should be Running with 0 restarts since restart
ssh k3s-master-01 'kubectl -n wazuh get pods -o custom-columns="NAME:.metadata.name,STATUS:.status.phase,RESTARTS:.status.containerStatuses[0].restartCount,AGE:.metadata.creationTimestamp"' | \
awk 'NR==1 {print; next} {
cmd="date -d " $4 " +%s"; cmd | getline ts; close(cmd);
age=systime()-ts;
status=($2=="Running" && $3==0) ? "✓" : "⚠";
printf "%s %-35s %-10s %-10s %dm\n", status, $1, $2, $3, age/60
}'
Phase 6: Post-Rotation Access Validation
CRITICAL: Validate NEW credentials work AFTER rotation.
# Set NEW_PASS for tests (retrieve from gopass if shell restarted)
NEW_PASS=$(gopass show -o v3/domains/d000/k3s/wazuh/indexer)
echo "NEW_PASS: ${NEW_PASS:0:5}..."
# TEST 1: Indexer API health with NEW password
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
curl -sk -u admin:$NEW_PASS https://localhost:9200/_cluster/health" | \
jq -C 'if .status == "green" then "✓ POST-CHECK: Indexer healthy (green)" elif .status then "⚠ POST-CHECK: Indexer status \(.status)" else "✗ POST-CHECK FAILED: No response" end'
# TEST 2: Verify OLD password NO LONGER works (uses known old password)
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- curl -sk -u admin:SecretPassword https://localhost:9200/_cluster/health" 2>&1 | grep -qE 'Unauthorized|401' && echo "✓ POST-CHECK: Old password rejected" || echo "✗ SECURITY ISSUE: Old password still works"
# TEST 3: Index count (verify data accessible)
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
curl -sk -u admin:$NEW_PASS https://localhost:9200/_cat/indices?v" | \
awk 'NR>1 {count++} END {print "✓ POST-CHECK: " count " indices accessible"}'
# TEST 4: Dashboard login page (via Traefik on 443)
curl -sk --connect-timeout 5 -o /dev/null -w "%{http_code}" https://wazuh.inside.domusdigitalis.dev/app/login | \
awk '{print ($1 == 200 || $1 == 302) ? "✓ POST-CHECK: Dashboard accessible (HTTP " $1 ")" : "✗ POST-CHECK: Dashboard returned HTTP " $1}'
# TEST 5: Manager API responding (401 = API is up, just needs auth)
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-manager-master-0 -c wazuh-manager -- \
curl -sk -o /dev/null -w '%{http_code}' https://localhost:55000/" | \
awk '{print ($1 == 401 || $1 == 200) ? "✓ POST-CHECK: Manager API responding (HTTP " $1 ")" : "✗ POST-CHECK: Manager API returned HTTP " $1}'
# TEST 6: No auth errors in logs (last 2 minutes)
ssh k3s-master-01 'kubectl -n wazuh logs wazuh-indexer-0 -c wazuh-indexer --since=2m | \
grep -ciE "authentication.*fail|unauthorized|401" | \
awk "{print (\$1 == 0) ? \"✓ POST-CHECK: No auth failures in indexer logs\" : \"✗ POST-CHECK: \" \$1 \" auth failures detected\"}"'
# Capture AFTER pod state (tee runs locally)
ssh k3s-master-01 'kubectl -n wazuh get pods -o json | \
jq -r ".items[] | \"\(.metadata.name),\(.status.phase),\(.status.containerStatuses[0].restartCount)\""' | \
tee /tmp/wazuh-pods-after.csv
# Display BEFORE state
echo "=== POD STATE COMPARISON ==="
echo "BEFORE:"
column -t -s',' /tmp/wazuh-pods-before.csv
# Display AFTER state (restart counts should be +1, no CrashLoopBackOff)
echo "AFTER:"
column -t -s',' /tmp/wazuh-pods-after.csv
Phase 7: Clean Up Exposed Settings
# Find and remove hardcoded Wazuh passwords
grep -rn "WAZUH.*PASSWORD.*=" ~/.claude/ 2>/dev/null | \
awk -F: '{print $1 ":" $2}' | sort -u
Edit each file to replace hardcoded values with variable references:
// REMOVE:
"Bash(WAZUH_INDEXER_PASSWORD=\"xRPz...\" ...)"
// REPLACE WITH:
"Bash(WAZUH_INDEXER_PASSWORD=$WAZUH_INDEXER_PASSWORD ...)"
# Delete pre-rotation backup (ONLY after all validation passes)
gopass rm v3/domains/d000/k3s/wazuh-pre-rotation
Rollback Plan
If rotation causes issues (e.g., "Unauthorized" after K8s secret update):
# Get old password from gopass backup
OLD_PASS=$(gopass show -o v3/domains/d000/k3s/wazuh-pre-rotation)
echo "Old password: $OLD_PASS"
# Verify old password still works in OpenSearch internal DB
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
curl -sk -u admin:$OLD_PASS https://localhost:9200/_cluster/health" | jq -C '.'
{
"cluster_name": "wazuh",
"status": "green",
"timed_out": false,
"number_of_nodes": 1,
"number_of_data_nodes": 1,
"discovered_master": true,
"discovered_cluster_manager": true,
"active_primary_shards": 33,
"active_shards": 33,
"relocating_shards": 0,
"initializing_shards": 0,
"unassigned_shards": 0,
"delayed_unassigned_shards": 0,
"number_of_pending_tasks": 0,
"number_of_in_flight_fetch": 0,
"task_max_waiting_in_queue_millis": 0,
"active_shards_percent_as_number": 100.0
}
# Revert K8s secret to old password
ssh k3s-master-01 "kubectl -n wazuh patch secret indexer-cred \
--type='json' \
-p='[{\"op\": \"replace\", \"path\": \"/data/password\", \"value\": \"'$(echo -n "$OLD_PASS" | base64)'\"}]'"
# Restart indexer to pick up reverted secret
ssh k3s-master-01 "kubectl -n wazuh rollout restart statefulset wazuh-indexer && \
kubectl -n wazuh rollout status statefulset wazuh-indexer --timeout=180s"
# Verify access restored
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
curl -sk -u admin:$OLD_PASS https://localhost:9200/_cluster/health" | jq -C '.'
{
"cluster_name": "wazuh",
"status": "green",
"timed_out": false,
"number_of_nodes": 1,
"number_of_data_nodes": 1,
"discovered_master": true,
"discovered_cluster_manager": true,
"active_primary_shards": 33,
"active_shards": 33,
"relocating_shards": 0,
"initializing_shards": 0,
"unassigned_shards": 0,
"delayed_unassigned_shards": 0,
"number_of_pending_tasks": 0,
"number_of_in_flight_fetch": 0,
"task_max_waiting_in_queue_millis": 0,
"active_shards_percent_as_number": 100.0
}
After rollback, restart from Phase 4 (line 257) to update OpenSearch internal user database.
Approval
| Role | Name | Date |
|---|---|---|
Requester |
evanusmodestus |
2026-02-25 |
Approver |
evanusmodestus |
2026-02-25 (Emergency) |
Post-Incident Actions
-
[ ] Audit all
settings.local.jsonfiles for other hardcoded credentials -
[ ] Document credential management best practices in domus-secrets-ops
-
[ ] Rotate
dashboard-credandwazuh-api-cred(low priority, not exposed) -
[ ] Implement Vault dynamic secrets (see stub)
-
[ ] Add pre-commit hook to detect hardcoded credentials
Lessons Learned
| Issue | Mitigation |
|---|---|
Hardcoded secrets in config files |
Use environment variables: |
Flat gopass structure |
Use resource-based paths: |
No pre/post validation |
Always test access BEFORE and AFTER rotation |
Credential purpose unclear |
Document which secret is for which component |
K8s secret ≠ OpenSearch password |
OpenSearch stores bcrypt hashes internally. Must run |
Container hash.sh fails (no Java) |
Generate bcrypt hash locally with Python: |
Assumed container paths |
Always verify paths with |
|
Overlayfs doesn’t support atomic rename. |
|
Requires |
ConfigMap/Secret mounts are read-only |
Write to |
Bcrypt hash contains |
|
awk range |
"admin:" matches BOTH patterns (starts with 'a'). Use |
Container missing |
securityadmin.sh uses |
zsh noclobber blocks |
Use |
K8s secret separate from OpenSearch DB |
Updating |
StatefulSet env var |
Env vars may show empty if |
Appendix: Container Discovery Patterns
When a file path doesn’t exist in a container, use these patterns to find it.
find - Locate Files
# Find file by name
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
find /usr/share/wazuh-indexer -name 'internal_users.yml' 2>/dev/null"
# Find all certs (.pem files)
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
find /usr/share/wazuh-indexer -name '*.pem' 2>/dev/null"
# Find all scripts (.sh files)
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
find /usr/share/wazuh-indexer -name '*.sh' 2>/dev/null"
# Find directories only (understand structure)
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
find /usr/share/wazuh-indexer -type d 2>/dev/null" | head -20
awk - Parse and Extract
# Extract hash value from internal_users.yml for admin user
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
awk '/^admin:/,/^[a-z]/' /usr/share/wazuh-indexer/config/opensearch-security/internal_users.yml" | \
awk '/hash:/ {print $2}'
# List all users in internal_users.yml (lines starting with word, ending with colon)
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
awk '/^[a-z]+:$/ {print}' /usr/share/wazuh-indexer/config/opensearch-security/internal_users.yml"
# Show file permissions in columnar format
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
ls -la /usr/share/wazuh-indexer/config/certs/" | \
awk '{printf "%-20s %-10s %-10s %s\n", $9, $1, $3, $4}'
Combined Discovery Workflow
# Step 1: Find all config directories
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
find /usr/share/wazuh-indexer -type d -name 'config' -o -name '*security*' 2>/dev/null"
# Step 2: List contents of discovered directory
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
ls -la /usr/share/wazuh-indexer/config/"
# Step 3: Read specific file
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- \
head -30 /usr/share/wazuh-indexer/config/opensearch-security/internal_users.yml"
Wazuh Indexer Container Paths (Discovered 2026-02-25)
| Purpose | Path |
|---|---|
Config root |
|
Security configs |
|
Certificates |
|
Security tools |
|
internal_users.yml |
|
securityadmin.sh |
|
admin cert |
|
admin key |
|
root CA |
|
Appendix: Post-Rotation Validation
Quick validation after rotation completes.
Dashboard Login
# Copy password to clipboard
gopass show -c v3/domains/d000/k3s/wazuh/indexer
# Open dashboard
echo "https://wazuh.inside.domusdigitalis.dev"
# Login: admin / <password from clipboard>
API Health Checks
# Set credential
NEW_PASS=$(gopass show -o v3/domains/d000/k3s/wazuh/indexer)
# Indexer cluster health (expect: green)
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- curl -sk -u admin:$NEW_PASS https://localhost:9200/_cluster/health" | jq -r '.status'
# Index count (expect: 25-30)
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- curl -sk -u admin:$NEW_PASS https://localhost:9200/_cat/indices" | wc -l
# No auth failures (expect: 0)
ssh k3s-master-01 'kubectl -n wazuh logs wazuh-indexer-0 -c wazuh-indexer --since=5m' | grep -ci "authentication.*fail"
# All pods running
ssh k3s-master-01 "kubectl -n wazuh get pods -o wide"
Wazuh API Calls
Two APIs available:
| API | Port | Auth | Use Case |
|---|---|---|---|
Indexer (OpenSearch) |
9200 |
Basic auth ( |
Query logs, indices, cluster health |
Manager |
55000 |
JWT token (get via |
Agent management, rules, decoders |
Indexer API (curl)
# Query recent alerts
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- curl -sk -u admin:$NEW_PASS 'https://localhost:9200/wazuh-alerts-*/_search?size=5'" | jq '.hits.hits[]._source.rule.description'
# Index stats
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-indexer-0 -c wazuh-indexer -- curl -sk -u admin:$NEW_PASS https://localhost:9200/_cat/indices?v"
Manager API (curl with JWT)
# Get JWT token
TOKEN=$(ssh k3s-master-01 "kubectl -n wazuh exec wazuh-manager-master-0 -c wazuh-manager -- curl -sk -X POST -u wazuh:wazuh https://localhost:55000/security/user/authenticate" | jq -r '.data.token')
echo "Token: ${TOKEN:0:20}..."
# List agents
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-manager-master-0 -c wazuh-manager -- curl -sk -H 'Authorization: Bearer $TOKEN' https://localhost:55000/agents" | jq '.data.affected_items[] | {id, name, status}'
# Manager info
ssh k3s-master-01 "kubectl -n wazuh exec wazuh-manager-master-0 -c wazuh-manager -- curl -sk -H 'Authorization: Bearer $TOKEN' https://localhost:55000/manager/info" | jq '.data'