#!/bin/bash
#
# cwp-wp-drift — WordPress core integrity + version drift checker
#
# Part of CloudWatch Pro (CWP) v1. Runs once a day on each cPanel server.
# Discovers every WordPress install on the server, runs:
#   1. wp core verify-checksums  →  catches modified core files (likely backdoor)
#   2. wp core check-update      →  flags outdated WP core
# Emits findings, sends digest email.
#
# Detect-only. Does NOT auto-update WP, restore tampered files, or take action.
#
# v1 scope:
#   ✓ WP core integrity (P1 if any core file modified)
#   ✓ WP core version drift (P2 if behind latest)
#   ✗ Plugin/theme checksum verification — deferred to v1.1
#     (slow + high FP rate for premium plugins; phishing-sweep covers
#     the most impactful case anyway: webshells in plugin dirs)
#   ✗ CVE/WPVulnDB integration — deferred (needs API keys)
#
# DUPLICATE BEHAVIOUR:
#   Integrity findings: state-based dedup. Re-alert only when the set of
#     modified files changes (new tamper, OR previous tamper cleaned up).
#   Outdated findings: daily reminder, no cooldown (similar to ssl-expiry).
#
# INSTALL:
#   sudo install -d /opt/cwp/agent/modules/wp-drift \
#                   /etc/cwp \
#                   /var/cwp/state/wp-drift \
#                   /var/cwp/findings \
#                   /var/log/cwp
#   sudo install -m 0755 cwp-wp-drift /opt/cwp/agent/modules/wp-drift/
#   sudo install -m 0644 config.example.conf /etc/cwp/wp-drift.conf
#   sudo $EDITOR /etc/cwp/wp-drift.conf
#   sudo crontab -l 2>/dev/null | { cat; cat cron.example; } | sudo crontab -
#
# Requires wp-cli at /usr/local/bin/wp (cPanel default location).
#
# USAGE:
#   cwp-wp-drift                      # normal run
#   cwp-wp-drift --dry-run            # report only
#   cwp-wp-drift --verbose            # log to stderr
#   cwp-wp-drift --no-email           # skip digest email
#   cwp-wp-drift --path /home/x/...   # check one specific install (testing)
#   cwp-wp-drift --version

set -euo pipefail

VERSION="0.1.0"
SCRIPT_NAME="cwp-wp-drift"

CONFIG_FILE="${CWP_WP_DRIFT_CONFIG:-/etc/cwp/wp-drift.conf}"
STATE_DIR="/var/cwp/state/wp-drift"
FINDINGS_DIR="/var/cwp/findings"
LOG_FILE="/var/log/cwp/wp-drift.log"
ALERT_EMAIL="root@localhost"
SERVER_NAME="$(hostname -f 2>/dev/null || hostname)"
SENDMAIL_BIN="/usr/sbin/sendmail"

WP_CLI_BIN="/usr/local/bin/wp"
SCAN_ROOTS=("/home")
DISCOVERY_MAX_DEPTH=6
PER_INSTALL_TIMEOUT=60
SUDO_BIN="/usr/bin/sudo"

DRY_RUN=0
VERBOSE=0
NO_EMAIL=0
SINGLE_PATH=""

# ---- helpers ----
log() {
  local level="$1"; shift
  local ts; ts="$(date '+%Y-%m-%d %H:%M:%S')"
  printf '%s [%s] %s\n' "$ts" "$level" "$*" >> "$LOG_FILE" 2>/dev/null || true
  if [[ "$VERBOSE" -eq 1 ]] || [[ "$level" == "ERROR" ]]; then
    printf '%s [%s] %s\n' "$ts" "$level" "$*" >&2
  fi
}
die() { log "ERROR" "$*"; exit 1; }
usage() { sed -n '1,40p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }

load_config() {
  # Shared CWP defaults (ALERT_EMAIL, SERVER_NAME, etc.) sourced first.
  if [[ -r /etc/cwp/common.conf ]]; then
    # shellcheck source=/dev/null
    . /etc/cwp/common.conf
  fi
  if [[ -r "$CONFIG_FILE" ]]; then
    # shellcheck source=/dev/null
    . "$CONFIG_FILE"
    log "INFO" "loaded config from $CONFIG_FILE"
  else
    log "WARN" "no config at $CONFIG_FILE — using built-in defaults"
  fi
  return 0
}

ensure_dirs() {
  for d in "$STATE_DIR" "$FINDINGS_DIR" "$(dirname "$LOG_FILE")"; do
    if [[ ! -d "$d" ]]; then
      mkdir -p "$d" 2>/dev/null || die "cannot create $d"
    fi
  done
}

preflight() {
  if [[ ! -x "$WP_CLI_BIN" ]]; then
    log "WARN" "wp-cli not found at $WP_CLI_BIN — module will exit cleanly"
    exit 0
  fi
  for cmd in find awk sed sha256sum; do
    if [[ "$cmd" == "sha256sum" ]]; then
      command -v sha256sum >/dev/null || command -v shasum >/dev/null \
        || die "neither sha256sum nor shasum found"
    else
      command -v "$cmd" >/dev/null || die "$cmd not found"
    fi
  done
  if [[ "$NO_EMAIL" -eq 0 ]] && [[ ! -x "$SENDMAIL_BIN" ]]; then
    log "WARN" "$SENDMAIL_BIN not found — email will be skipped"
  fi
}

json_escape() {
  local s="$1"
  s="${s//\\/\\\\}"; s="${s//\"/\\\"}"
  s="${s//$'\n'/\\n}"; s="${s//$'\t'/\\t}"; s="${s//$'\r'/\\r}"
  printf '%s' "$s"
}

sha256_of_string() {
  if command -v sha256sum >/dev/null 2>&1; then
    printf '%s' "$1" | sha256sum | awk '{print $1}'
  else
    printf '%s' "$1" | shasum -a 256 | awk '{print $1}'
  fi
}

# discover_wp_installs — emit one path per detected WordPress install
discover_wp_installs() {
  if [[ -n "$SINGLE_PATH" ]]; then
    if [[ -f "$SINGLE_PATH/wp-config.php" ]]; then
      printf '%s\n' "$SINGLE_PATH"
    else
      log "WARN" "$SINGLE_PATH does not contain wp-config.php"
    fi
    return
  fi

  local root
  for root in "${SCAN_ROOTS[@]}"; do
    [[ -d "$root" ]] || continue
    find "$root" -maxdepth "$DISCOVERY_MAX_DEPTH" -name 'wp-config.php' -type f 2>/dev/null | \
      while IFS= read -r config_path; do
        dirname "$config_path"
      done
  done | sort -u
}

# wp_run <wp_path> <owner> <args...> — runs wp-cli as the install owner
wp_run() {
  local wp_path="$1"; shift
  local owner="$1"; shift
  if [[ -x "$SUDO_BIN" ]] && [[ "$(id -u)" -eq 0 ]] && [[ -n "$owner" ]] && [[ "$owner" != "root" ]]; then
    timeout "$PER_INSTALL_TIMEOUT" "$SUDO_BIN" -u "$owner" -H "$WP_CLI_BIN" --path="$wp_path" "$@" 2>&1
  else
    timeout "$PER_INSTALL_TIMEOUT" "$WP_CLI_BIN" --allow-root --path="$wp_path" "$@" 2>&1
  fi
}

# emit_finding <wp_path> <owner> <metric> <severity> <subject> <details_blob> <action> <state_hash>
emit_finding() {
  local wp_path="$1" owner="$2" metric="$3" sev="$4" subject="$5" details="$6" action="$7" state_hash="$8"
  local now_iso now_epoch finding_file id

  now_epoch="$(date +%s)"
  now_iso="$(date '+%Y-%m-%dT%H:%M:%S%z')"
  id="wp-drift-${metric}-${SERVER_NAME}-$(printf '%s' "$wp_path" | sha256_of_string | head -c 16)"
  finding_file="$FINDINGS_DIR/findings.jsonl"

  local subj_esc details_esc action_esc owner_esc path_esc
  subj_esc="$(json_escape "$subject")"
  details_esc="$(json_escape "$details")"
  action_esc="$(json_escape "$action")"
  owner_esc="$(json_escape "$owner")"
  path_esc="$(json_escape "$wp_path")"

  local json
  json=$(printf '{"ts":"%s","ts_epoch":%d,"module":"%s","server":"%s","severity":"%s","metric":"%s","wp_path":"%s","account":"%s","subject":"%s","details":"%s","id":"%s","recommended_action":"%s"}' \
    "$now_iso" "$now_epoch" "wp-drift" "$SERVER_NAME" "$sev" "$metric" \
    "$path_esc" "$owner_esc" "$subj_esc" "$details_esc" \
    "$id" "$action_esc")

  if [[ "$DRY_RUN" -eq 1 ]]; then
    printf 'DRY-RUN finding: %s\n' "$json"
  else
    printf '%s\n' "$json" >> "$finding_file"
    if [[ -n "$state_hash" ]]; then
      printf '%s' "$state_hash" > "$STATE_DIR/state.${id}"
    fi
  fi

  DIGEST_LINES+=("[$sev] ${metric}  ${wp_path}  ${subject}")
  if [[ "$sev" == "P1" ]]; then HAS_P1=1; fi
}

# read_state <id>
read_state() {
  local id="$1"
  local f="$STATE_DIR/state.${id}"
  [[ -f "$f" ]] && cat "$f" || echo ""
}

# clear_state <id>
clear_state() {
  rm -f "$STATE_DIR/state.${1}" 2>/dev/null
}

# check_install <wp_path>
check_install() {
  local wp_path="$1"

  # Determine the cPanel account that owns this install
  local owner
  owner="$(stat -c %U "$wp_path/wp-config.php" 2>/dev/null || stat -f %Su "$wp_path/wp-config.php" 2>/dev/null || echo "")"
  log "INFO" "checking $wp_path (owner=${owner:-unknown})"

  # Get WP core version
  local core_version
  core_version="$(wp_run "$wp_path" "$owner" core version 2>/dev/null | tr -d '\r' | head -1)"
  if [[ -z "$core_version" ]]; then
    log "WARN" "$wp_path: cannot determine WP version (likely broken install) — skipping"
    return
  fi

  # ---- Check 1: core file integrity ----
  local checksum_output
  checksum_output="$(wp_run "$wp_path" "$owner" core verify-checksums 2>&1 || true)"

  # Extract list of files that don't verify
  local modified_files
  modified_files="$(printf '%s\n' "$checksum_output" \
    | grep -E "(don't verify against checksum|doesn't verify|should not exist)" \
    | sed -E 's/.*(against checksum: |should not exist: )//;s/[[:space:]]+$//' \
    | sort -u)"

  local id_core="wp-drift-wp_core_integrity-${SERVER_NAME}-$(printf '%s' "$wp_path" | sha256_of_string | head -c 16)"

  if [[ -n "$modified_files" ]]; then
    local file_count; file_count="$(printf '%s\n' "$modified_files" | grep -c .)"
    local files_summary; files_summary="$(printf '%s\n' "$modified_files" | head -10 | tr '\n' ',' | sed 's/,$//')"
    local current_hash; current_hash="$(sha256_of_string "$modified_files")"
    local last_hash; last_hash="$(read_state "$id_core")"

    if [[ "$current_hash" != "$last_hash" ]]; then
      local action
      action="WP core integrity check failed for ${wp_path} (${file_count} file(s) modified). This is highly unusual for a normal WordPress install — likely indicates a backdoor injected into core files. Inspect the listed files. To restore from official source: cd ${wp_path} && wp core download --skip-content --force --allow-root. Then re-run wp core verify-checksums to confirm clean."
      emit_finding "$wp_path" "$owner" "wp_core_integrity" "P1" \
        "${file_count} core file(s) modified" \
        "$files_summary" "$action" "$current_hash"
    else
      log "INFO" "$wp_path: core integrity violations unchanged since last alert — suppressing"
    fi
  else
    # Core integrity is clean — clear stale state
    if [[ -n "$(read_state "$id_core")" ]]; then
      log "INFO" "$wp_path: core integrity restored — clearing state"
      [[ "$DRY_RUN" -eq 0 ]] && clear_state "$id_core"
    fi
  fi

  # ---- Check 2: outdated core version ----
  local update_output update_version
  update_output="$(wp_run "$wp_path" "$owner" core check-update --field=version 2>/dev/null || true)"
  update_version="$(printf '%s\n' "$update_output" | grep -E '^[0-9]+\.[0-9]+' | head -1 | tr -d '\r')"

  if [[ -n "$update_version" ]] && [[ "$update_version" != "$core_version" ]]; then
    local action
    action="WordPress at ${wp_path} is on ${core_version}, ${update_version} is available. Update via: cd ${wp_path} && wp core update --allow-root && wp core update-db --allow-root. Test the site after update."
    emit_finding "$wp_path" "$owner" "wp_core_outdated" "P2" \
      "${core_version} → ${update_version}" \
      "current=${core_version} available=${update_version}" "$action" ""
  else
    log "INFO" "$wp_path: WP ${core_version} is up to date"
  fi
}

# send_alert_email
send_alert_email() {
  local count="${#DIGEST_LINES[@]}"
  if (( count == 0 )); then
    log "INFO" "no findings — email NOT sent"
    return
  fi
  if [[ "$NO_EMAIL" -eq 1 ]]; then
    log "INFO" "$count finding(s), --no-email — email skipped"
    return
  fi

  local subject body now_str findings_block sev_tag
  now_str="$(date '+%Y-%m-%d %H:%M:%S %Z')"
  findings_block="$(printf '%s\n' "${DIGEST_LINES[@]}")"
  sev_tag="P2"
  [[ "$HAS_P1" -eq 1 ]] && sev_tag="P1"
  subject="[CWP ${sev_tag}] wp-drift: ${count} finding(s) on ${SERVER_NAME}"

  IFS='' read -r -d '' body <<EOF || true
CloudWatch Pro — WordPress Core Integrity + Version Drift

Server:    ${SERVER_NAME}
Run time:  ${now_str}
Findings:  ${count}
WP installs scanned: ${TOTAL_INSTALLS}

----- findings -----

${findings_block}

----- next steps -----

P1 (wp_core_integrity): WordPress core file modified.
  This is unusual. Almost no legitimate workflow modifies core files —
  WP itself overwrites them on update. Common cause: backdoor injection.

  Inspect the modified file(s) listed in the recommended_action of each
  finding (in /var/cwp/findings/findings.jsonl).

  Restore from official source:
      cd <wp_path>
      wp core download --skip-content --force --allow-root
      wp core verify-checksums --allow-root

  After restoration, audit the account for related compromises:
      Cross-check phishing-sweep + login-failures findings on this server.
      Force WP admin password reset:
          wp user reset-password --all --allow-root --path=<wp_path>

P2 (wp_core_outdated): WordPress core is behind the latest release.
  Update during a maintenance window:
      cd <wp_path>
      wp core update --allow-root
      wp core update-db --allow-root
      # Test the site

This module is detect-only. CWP did NOT update or restore any file.

Findings file: /var/cwp/findings/findings.jsonl
WP-drift log:  /var/log/cwp/wp-drift.log
EOF

  if [[ "$DRY_RUN" -eq 1 ]]; then
    printf 'DRY-RUN email to %s:\n  Subject: %s\n%s\n' "$ALERT_EMAIL" "$subject" "$body"
    return
  fi
  if [[ ! -x "$SENDMAIL_BIN" ]]; then
    log "WARN" "sendmail not available; alert email NOT sent"
    return
  fi

  {
    printf 'To: %s\n' "$ALERT_EMAIL"
    printf 'From: cwp-agent@%s\n' "$SERVER_NAME"
    printf 'Subject: %s\n' "$subject"
    printf 'X-CWP-Module: wp-drift\n'
    printf 'X-CWP-Severity: %s\n' "$sev_tag"
    printf 'Content-Type: text/plain; charset=utf-8\n'
    printf '\n%s\n' "$body"
  } | "$SENDMAIL_BIN" -t -i

  log "INFO" "alert email sent to $ALERT_EMAIL with $count finding(s)"
}

# ---- argument parsing ----
while [[ $# -gt 0 ]]; do
  case "$1" in
    --dry-run)  DRY_RUN=1; shift ;;
    --verbose)  VERBOSE=1; shift ;;
    --no-email) NO_EMAIL=1; shift ;;
    --path)     SINGLE_PATH="$2"; shift 2 ;;
    --version)  printf '%s %s\n' "$SCRIPT_NAME" "$VERSION"; exit 0 ;;
    -h|--help)  usage 0 ;;
    *)          printf 'unknown argument: %s\n' "$1" >&2; usage 2 ;;
  esac
done

# ---- main ----
load_config
ensure_dirs
preflight

DIGEST_LINES=()
HAS_P1=0
TOTAL_INSTALLS=0

log "INFO" "$SCRIPT_NAME v$VERSION starting (server=$SERVER_NAME, dry_run=$DRY_RUN)"

while IFS= read -r install_path; do
  [[ -z "$install_path" ]] && continue
  TOTAL_INSTALLS=$((TOTAL_INSTALLS + 1))
  check_install "$install_path"
done < <(discover_wp_installs)

log "INFO" "scanned $TOTAL_INSTALLS WordPress install(s)"

send_alert_email

log "INFO" "$SCRIPT_NAME complete: ${#DIGEST_LINES[@]} finding(s) emitted"
exit 0
