#!/usr/bin/env bash
# wp-harden-audit — read-only WordPress hardening audit.
# https://westoakbridge.co/wordpress-hardening-checklist
#
# Checks a WordPress install (via WP-CLI) and/or a live URL (via curl) against the
# checklist at the URL above. It never changes anything.
#
# Usage:
#   wp-harden-audit.sh --path=/var/www/html            # server-side checks (needs wp-cli)
#   wp-harden-audit.sh --url=https://example.com       # external checks only (needs curl)
#   wp-harden-audit.sh --path=/var/www/html --url=https://example.com
#
# Options:
#   --path=DIR     WordPress root (runs WP-CLI checks)
#   --url=URL      Public site URL (runs HTTP checks)
#   --wp=CMD       WP-CLI command (default: "wp"; e.g. "wp --allow-root")
#   --no-color     Plain output
#
# Exit code: 0 = no FAIL results, 1 = at least one FAIL, 2 = usage error.

set -uo pipefail

WP_PATH=""
SITE_URL=""
WP_CMD="wp"
COLOR=1

for arg in "$@"; do
  case "$arg" in
    --path=*) WP_PATH="${arg#*=}" ;;
    --url=*) SITE_URL="${arg#*=}" ;;
    --wp=*) WP_CMD="${arg#*=}" ;;
    --no-color) COLOR=0 ;;
    -h | --help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
    *) echo "Unknown option: $arg (see --help)" >&2; exit 2 ;;
  esac
done

if [[ -z "$WP_PATH" && -z "$SITE_URL" ]]; then
  echo "Give --path, --url, or both (see --help)." >&2
  exit 2
fi
[[ -t 1 ]] || COLOR=0
SITE_URL="${SITE_URL%/}"

if [[ $COLOR -eq 1 ]]; then
  C_PASS=$'\e[32m' C_WARN=$'\e[33m' C_FAIL=$'\e[31m' C_INFO=$'\e[36m' C_DIM=$'\e[2m' C_OFF=$'\e[0m'
else
  C_PASS="" C_WARN="" C_FAIL="" C_INFO="" C_DIM="" C_OFF=""
fi

PASS=0 WARN=0 FAIL=0
pass() { PASS=$((PASS + 1)); printf '  %sPASS%s  %s\n' "$C_PASS" "$C_OFF" "$1"; }
warn() { WARN=$((WARN + 1)); printf '  %sWARN%s  %s\n' "$C_WARN" "$C_OFF" "$1"; [[ -n "${2:-}" ]] && printf '        %s↳ %s%s\n' "$C_DIM" "$2" "$C_OFF"; return 0; }
fail() { FAIL=$((FAIL + 1)); printf '  %sFAIL%s  %s\n' "$C_FAIL" "$C_OFF" "$1"; [[ -n "${2:-}" ]] && printf '        %s↳ %s%s\n' "$C_DIM" "$2" "$C_OFF"; return 0; }
info() { printf '  %sINFO%s  %s\n' "$C_INFO" "$C_OFF" "$1"; }
section() { printf '\n%s\n' "$1"; }

# --------------------------------------------------------------------------- server-side
wp_run() {
  # shellcheck disable=SC2086 # WP_CMD may carry flags, e.g. "wp --allow-root"
  $WP_CMD --path="$WP_PATH" --skip-plugins --skip-themes "$@" 2>/dev/null
}

# Portable octal permissions (GNU and BSD stat).
file_mode() { stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null; }

if [[ -n "$WP_PATH" ]]; then
  # shellcheck disable=SC2086
  if ! command -v ${WP_CMD%% *} >/dev/null 2>&1; then
    echo "WP-CLI not found (${WP_CMD%% *}). Install it or pass --wp=..." >&2
    exit 2
  fi
  if ! wp_run core is-installed; then
    echo "No WordPress install found at $WP_PATH" >&2
    exit 2
  fi

  section "Core & updates"
  version="$(wp_run core version)"
  info "WordPress $version"
  core_updates="$(wp_run core check-update --format=count)"
  if [[ "${core_updates:-0}" -gt 0 ]]; then
    fail "Core update available" "wp core update (after a backup)"
  else
    pass "Core is up to date"
  fi

  plugin_updates="$(wp_run plugin list --update=available --format=count)"
  if [[ "${plugin_updates:-0}" -gt 0 ]]; then
    fail "$plugin_updates plugin(s) have updates available" "wp plugin list --update=available"
  else
    pass "All plugins up to date"
  fi

  theme_updates="$(wp_run theme list --update=available --format=count)"
  if [[ "${theme_updates:-0}" -gt 0 ]]; then
    warn "$theme_updates theme(s) have updates available" "wp theme list --update=available"
  else
    pass "All themes up to date"
  fi

  inactive_plugins="$(wp_run plugin list --status=inactive --format=count)"
  if [[ "${inactive_plugins:-0}" -gt 0 ]]; then
    warn "$inactive_plugins inactive plugin(s) still installed" "Inactive code is still reachable — delete, don't just deactivate"
  else
    pass "No inactive plugins installed"
  fi

  inactive_themes="$(wp_run theme list --status=inactive --format=count)"
  if [[ "${inactive_themes:-0}" -gt 1 ]]; then
    warn "$inactive_themes inactive themes installed" "Keep one default theme as a fallback, delete the rest"
  else
    pass "No unused themes beyond one fallback"
  fi

  section "Accounts"
  admin_count="$(wp_run user list --role=administrator --format=count)"
  if [[ "${admin_count:-0}" -gt 3 ]]; then
    warn "$admin_count administrator accounts" "Review: most people need Editor, not Administrator"
  else
    pass "$admin_count administrator account(s)"
  fi
  if wp_run user get admin --field=ID >/dev/null; then
    fail "A user named \"admin\" exists" "The first username every brute-force tool tries — rename or replace it"
  else
    pass "No user named \"admin\""
  fi

  section "Configuration"
  # Runtime value (wp-config.php, mu-plugins, and WordPress defaults), not just what wp-config says.
  bool_const() { wp_run eval "echo (defined('$1') && constant('$1')) ? 'true' : 'false';"; }

  if [[ "$(bool_const DISALLOW_FILE_EDIT)" =~ ^(1|true)$ ]]; then
    pass "Built-in file editor disabled (DISALLOW_FILE_EDIT)"
  else
    fail "Built-in theme/plugin editor is enabled" "define( 'DISALLOW_FILE_EDIT', true );"
  fi

  if [[ "$(bool_const WP_DEBUG)" =~ ^(1|true)$ ]]; then
    if [[ "$(bool_const WP_DEBUG_DISPLAY)" =~ ^(0|false)$ ]]; then
      warn "WP_DEBUG is on (display off)" "Fine while debugging; turn it off in production"
    else
      fail "WP_DEBUG is on and errors are displayed" "Leaks paths and internals — set WP_DEBUG_DISPLAY to false"
    fi
  else
    pass "WP_DEBUG is off"
  fi

  if [[ "$(bool_const FORCE_SSL_ADMIN)" =~ ^(1|true)$ ]] || [[ "$(wp_run option get siteurl)" == https://* ]]; then
    pass "Admin served over HTTPS"
  else
    fail "Site URL is not HTTPS" "Serve the whole site over HTTPS and set FORCE_SSL_ADMIN"
  fi

  config_file="$WP_PATH/wp-config.php"
  [[ -f "$config_file" ]] || config_file="$(dirname "$WP_PATH")/wp-config.php"
  if [[ -f "$config_file" ]]; then
    mode="$(file_mode "$config_file")"
    if [[ -n "$mode" && "${mode: -1}" != "0" ]]; then
      warn "wp-config.php is world-readable (mode $mode)" "chmod 640 (or 600/440) — it holds database credentials"
    else
      pass "wp-config.php is not world-readable (mode ${mode:-?})"
    fi
  fi

  if [[ "$(wp_run option get users_can_register)" == "1" ]]; then
    default_role="$(wp_run option get default_role)"
    if [[ "$default_role" == "administrator" || "$default_role" == "editor" ]]; then
      fail "Open registration with default role \"$default_role\"" "Classic takeover setting — disable registration or set the role to subscriber"
    else
      warn "Open user registration is enabled (role: $default_role)" "Turn off unless the site genuinely needs sign-ups"
    fi
  else
    pass "Open user registration is disabled"
  fi
fi

# --------------------------------------------------------------------------- external
if [[ -n "$SITE_URL" ]]; then
  command -v curl >/dev/null || { echo "curl is required for --url checks" >&2; exit 2; }
  UA="wp-harden-audit/1.0 (+https://westoakbridge.co/wordpress-hardening-checklist)"
  http_code() { curl -s -o /dev/null -w '%{http_code}' -A "$UA" --max-time 15 "$@"; }
  http_body() { curl -s -A "$UA" --max-time 15 "$@"; }

  section "External: $SITE_URL"

  headers="$(curl -s -D - -o /dev/null -A "$UA" --max-time 15 "$SITE_URL/" | tr -d '\r' | tr '[:upper:]' '[:lower:]')"
  if [[ -z "$headers" ]]; then
    fail "Site did not respond" "Check the URL"
  else
    [[ "$SITE_URL" == https://* ]] || fail "URL is not HTTPS"
    if [[ "$SITE_URL" == https://* ]]; then
      insecure="${SITE_URL/https:/http:}"
      redirect="$(curl -s -o /dev/null -w '%{redirect_url}' -A "$UA" --max-time 15 "$insecure/")"
      if [[ "$redirect" == https://* ]]; then pass "HTTP redirects to HTTPS"; else fail "HTTP does not redirect to HTTPS"; fi
    fi

    check_header() { # name, advice
      if grep -q "^$1:" <<<"$headers"; then pass "Header: $1"; else warn "Missing header: $1" "$2"; fi
    }
    check_header "strict-transport-security" "max-age=31536000; includeSubDomains"
    check_header "x-content-type-options" "nosniff"
    check_header "referrer-policy" "strict-origin-when-cross-origin"
    if grep -qE '^x-frame-options:|^content-security-policy:.*frame-ancestors' <<<"$headers"; then
      pass "Clickjacking protection (X-Frame-Options / frame-ancestors)"
    else
      warn "No clickjacking protection" "X-Frame-Options: SAMEORIGIN or CSP frame-ancestors 'self'"
    fi
    if grep -qE '^x-powered-by:.*php/[0-9]' <<<"$headers"; then
      warn "PHP version exposed in X-Powered-By" "expose_php = Off in php.ini"
    else
      pass "PHP version not exposed"
    fi
  fi

  home="$(http_body "$SITE_URL/")"
  if grep -qiE '<meta name="generator" content="WordPress [0-9]' <<<"$home"; then
    warn "WordPress version in generator meta tag" "remove_action( 'wp_head', 'wp_generator' ); — see mu-plugins/"
  else
    pass "WordPress version not advertised in generator tag"
  fi

  xmlrpc_code="$(http_code -X POST -H 'Content-Type: text/xml' \
    --data '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>' \
    "$SITE_URL/xmlrpc.php")"
  if [[ "$xmlrpc_code" == "200" ]]; then
    warn "xmlrpc.php answers requests" "Brute-force amplification + pingback abuse — block it unless an app needs it"
  else
    pass "xmlrpc.php blocked (HTTP $xmlrpc_code)"
  fi

  # Pretty permalinks use /wp-json/; plain permalinks only answer ?rest_route=.
  users_json="$(http_body "$SITE_URL/wp-json/wp/v2/users")$(http_body "$SITE_URL/?rest_route=/wp/v2/users")"
  if grep -q '"slug"' <<<"$users_json"; then
    warn "REST API lists users (/wp-json/wp/v2/users)" "Hands attackers your login names — see mu-plugins/"
  else
    pass "REST API does not list users"
  fi

  # Pretty permalinks redirect to /author/<name>/; plain ones render a page with an author-<name> body class.
  author_redirect="$(curl -s -o /dev/null -w '%{redirect_url}' -A "$UA" --max-time 15 "$SITE_URL/?author=1")"
  author_leak="${author_redirect##*/author/}"
  [[ "$author_redirect" == *"/author/"* ]] || author_leak=""
  if [[ -z "$author_leak" ]]; then
    author_leak="$(http_body "$SITE_URL/?author=1" | grep -oE '<body[^>]*class="[^"]*author-[a-z0-9_.-]+ author-1' | grep -oE 'author-[a-z0-9_.-]+ author-1' | sed -E 's/^author-//; s/ author-1$//' | head -n1)"
  fi
  if [[ -n "$author_leak" ]]; then
    warn "?author=1 reveals a username (${author_leak%/})" "Block author enumeration — see mu-plugins/"
  else
    pass "?author=N does not reveal usernames"
  fi

  leaked=0
  for path in wp-config.php.bak wp-config.php~ wp-config.php.save wp-config.txt .env .git/HEAD wp-content/debug.log; do
    if [[ "$(http_code "$SITE_URL/$path")" == "200" ]]; then
      leaked=1
      fail "Exposed file: /$path" "Delete it, and deny access to backups/dotfiles at the web server"
    fi
  done
  [[ $leaked -eq 0 ]] && pass "No common leaked files (config backups, .env, .git, debug.log)"

  listing="$(http_body "$SITE_URL/wp-content/uploads/")"
  if grep -qi '<title>Index of' <<<"$listing"; then
    fail "Directory listing enabled on /wp-content/uploads/" "Options -Indexes (Apache) or autoindex off (nginx)"
  else
    pass "No directory listing on uploads"
  fi

  readme_code="$(http_code "$SITE_URL/readme.html")"
  if [[ "$readme_code" == "200" ]]; then
    warn "/readme.html is public" "Minor fingerprinting — delete it or deny it at the server"
  else
    pass "/readme.html not exposed"
  fi
fi

# --------------------------------------------------------------------------- summary
printf '\n%s%d passed%s, %s%d warnings%s, %s%d failed%s\n' \
  "$C_PASS" "$PASS" "$C_OFF" "$C_WARN" "$WARN" "$C_OFF" "$C_FAIL" "$FAIL" "$C_OFF"
printf '%sA clean result covers configuration only — it is not a security audit.%s\n' "$C_DIM" "$C_OFF"
[[ $FAIL -eq 0 ]]
