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

Rotation Flow

Wazuh Credential Rotation Flow

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

indexer-cred

admin

OpenSearch (Indexer)

Admin API access, cluster management, data queries

dashboard-cred

kibanaserver

Dashboard → Indexer

Backend auth for dashboard to query indexer

wazuh-api-cred

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

In Scope

  • Rotate indexer-cred secret password

  • Restructure gopass: wazuh/wazuh/indexer, wazuh/dashboard, wazuh/api

  • Remove hardcoded credential from settings.local.json

  • Restart affected pods

  • Pre/post access validation

Out of Scope

  • dashboard-cred rotation (not exposed, but consider as follow-up)

  • wazuh-api-cred rotation (not exposed)

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:

# Retrieve password from gopass (already stored)
NEW_PASS=$(gopass show -o v3/domains/d000/k3s/wazuh/indexer)
[[ -n "$NEW_PASS" ]] && echo "✓ NEW_PASS retrieved from gopass: ${NEW_PASS:0:5}..." || \
  echo "✗ Not in gopass yet - run Phase 1 below"
# Regenerate bcrypt hash from stored password
BCRYPT_HASH=$(python3 -c "import bcrypt; print(bcrypt.hashpw(b'$NEW_PASS', bcrypt.gensalt(rounds=12)).decode())")
echo "✓ BCRYPT_HASH: ${BCRYPT_HASH:0:20}..."

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/
Expected output
v3/domains/d000/k3s/wazuh/api
v3/domains/d000/k3s/wazuh/dashboard
v3/domains/d000/k3s/wazuh/indexer
output confirmation 2026-02-25 11:00
# 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 .opendistro_security index. Updating K8s secret alone does NOT change the password. Must run securityadmin.sh.

Why local Python instead of container hash.sh?

The container’s hash.sh tool fails because Java isn’t in PATH:

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"; }
Expected output format
✓ Hash generated: $2b$12$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Step 4.3: Backup internal_users.yml

cp works in containers (creates new file directly). sed -i fails because it creates temp then renames (overlayfs blocks rename).

# 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:

  • sed -i fails (overlayfs doesn’t support atomic rename)

  • kubectl cp fails (requires tar which minimal containers lack)

Solution: Use cat to read/write via stdin/stdout.

# 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 $ characters (e.g., $2b$12$…​). In double-quoted strings, bash expands $2b as a variable → empty string. awk’s -v flag handles special characters correctly.

# 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 opensearch-security/ directory is mounted read-only (ConfigMap/Secret). Write to /tmp instead, then point securityadmin.sh to it in Step 4.6.

# 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"}'
Expected output
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 indexer-cred Kubernetes secret. Without updating this secret, they will keep trying to connect with the OLD password.

# 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\"}}'"
Expected output
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 INDEXER_PASSWORD from the indexer-cred secret via valueFrom.secretKeyRef. If this reference is missing (shows empty env var), the pod won’t pick up the new password even after restart.

# 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")'
Expected output (GOOD - has valueFrom)
{
  "name": "INDEXER_PASSWORD",
  "valueFrom": {
    "secretKeyRef": {
      "key": "password",
      "name": "indexer-cred"
    }
  }
}
Bad output (MISSING valueFrom - needs fix)
{
  "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"}}}}]'\'''
Expected output
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:

ssh k3s-master-01 "kubectl -n wazuh get pods -o custom-columns='NAME:.metadata.name,AGE:.metadata.creationTimestamp' | \
  awk 'NR>1 {cmd=\"date -d \" \$2 \" +%s\"; cmd | getline ts; close(cmd); \
       age=systime()-ts; printf \"%-35s %dm ago\\n\", \$1, age/60}'"

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"; }
Output confirmed 2026-02-25 11:04
❯ # 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 '.'
Output validated 2026-02-25 11:19
{
  "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 '.'
Output verified 2026-02-25 11:25
{
  "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

  1. [ ] Audit all settings.local.json files for other hardcoded credentials

  2. [ ] Document credential management best practices in domus-secrets-ops

  3. [ ] Rotate dashboard-cred and wazuh-api-cred (low priority, not exposed)

  4. [ ] Implement Vault dynamic secrets (see stub)

  5. [ ] Add pre-commit hook to detect hardcoded credentials

Lessons Learned

Issue Mitigation

Hardcoded secrets in config files

Use environment variables: $VAR not "literal"

Flat gopass structure

Use resource-based paths: /wazuh/indexer, /wazuh/api

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 securityadmin.sh

Container hash.sh fails (no Java)

Generate bcrypt hash locally with Python: python3 -c "import bcrypt; …​"

Assumed container paths

Always verify paths with find before writing runbooks. Container layouts vary.

sed -i fails in containers

Overlayfs doesn’t support atomic rename.

kubectl cp fails in minimal containers

Requires tar binary. Use cat via stdin/stdout instead.

ConfigMap/Secret mounts are read-only

Write to /tmp inside container, point tools there.

Bcrypt hash contains $ chars

sed in double quotes expands $2b as variable. Use awk -v instead.

awk range /admin:/,/[a-zA-Z]/ fails

"admin:" matches BOTH patterns (starts with 'a'). Use grep -A1 instead.

Container missing which command

securityadmin.sh uses which to find Java. Set JAVA_HOME explicitly via env.

zsh noclobber blocks >

Use >| to force overwrite in zsh shells with noclobber set.

K8s secret separate from OpenSearch DB

Updating internal_users.yml doesn’t update indexer-cred secret. Other pods (manager, dashboard) read from the K8s secret - must patch it separately and restart pods.

StatefulSet env var valueFrom can go missing

Env vars may show empty if valueFrom.secretKeyRef reference is stripped. Check with jq and patch statefulset if needed.

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

/usr/share/wazuh-indexer/config/

Security configs

/usr/share/wazuh-indexer/config/opensearch-security/

Certificates

/usr/share/wazuh-indexer/config/certs/

Security tools

/usr/share/wazuh-indexer/plugins/opensearch-security/tools/

internal_users.yml

/usr/share/wazuh-indexer/config/opensearch-security/internal_users.yml

securityadmin.sh

/usr/share/wazuh-indexer/plugins/opensearch-security/tools/securityadmin.sh

admin cert

/usr/share/wazuh-indexer/config/certs/admin.pem

admin key

/usr/share/wazuh-indexer/config/certs/admin-key.pem

root CA

/usr/share/wazuh-indexer/config/certs/root-ca.pem

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 (admin:password)

Query logs, indices, cluster health

Manager

55000

JWT token (get via /security/user/authenticate)

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'