585984cfe2
Wordfence trae loginSec_disableApplicationPasswords=1 de fabrica. Con eso wp_is_application_passwords_available() devuelve false, la seccion desaparece del perfil sin dar ni un aviso, y la autenticacion por REST responde 401. Los bots de la carta publican asi (publicabot, de icalvotorre), o sea que estaban sin poder publicar y la carta 738 se compone manana. Lo introdujimos nosotros: en produccion Wordfence no estaba instalado, se anadio el 31-jul. No salio en el ensayo porque probamos lectura de la REST API pero nunca autenticacion. Lo detecto Inma; la traza es un 401 real suyo contra /wp-json/wp/v2/users/me a las 11:14 UTC. Aplicado en produccion y comprobado con su prueba de humo: POST a /wp-json/wp/v2/posts devuelve 201, y el ultimo uso de publicabot pasa de 29-jul a hoy. Se anade como paso 3e y no como arreglo de una vez porque el paso 3d reinstala Wordfence si falta, y una instalacion nueva vuelve a ponerlo a 1. Dos detalles del propio script, que fallaban en silencio: - va por heredoc y no por la funcion wp(), porque esa expande "$*" y se come las comillas del codigo PHP. - y sin --skip-plugins, o la clase wfConfig de Wordfence no existe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
182 lines
9.2 KiB
Bash
182 lines
9.2 KiB
Bash
#!/bin/bash
|
|
#
|
|
# Higiene de seguridad posterior a importar la base de datos en el servidor nuevo.
|
|
# Parte del cutover CDMON -> Hetzner (gitea rafa/feadulta #180, criterio del comment-516).
|
|
#
|
|
# SE EJECUTA DESPUES DE CADA IMPORT. El dump trae las cuentas de administrador de
|
|
# produccion con sus hashes, incluidas las tres deshabilitadas durante el incidente #183,
|
|
# asi que reimportar las revive. Por eso esto es un script y no una tarea manual.
|
|
#
|
|
# Lee las contrasenas de ~/.hermes/profiles/feadulta/.env (claves FEA_WP_PASS_*).
|
|
# Nunca las imprime ni las pasa por argv: viajan por variable de entorno del proceso.
|
|
#
|
|
# Uso: bash post-import-seguridad.sh [--dry-run]
|
|
#
|
|
set -euo pipefail
|
|
|
|
DRY=0
|
|
[ "${1:-}" = "--dry-run" ] && DRY=1
|
|
|
|
ENVF=~/.hermes/profiles/feadulta/.env
|
|
[ -f "$ENVF" ] || { echo "Falta $ENVF"; exit 1; }
|
|
set -a; . "$ENVF"; set +a
|
|
|
|
SRV=root@188.40.120.157
|
|
U=r2ssjifwj0r0ghyoqd528uaa # servicio feadulta-wp en Coolify
|
|
WPC=wordpress-$U
|
|
|
|
# usuario:ID:variable_de_password
|
|
ADMINS_OK="eqpyk:1:FEA_WP_PASS_EQPYK icalvotorre:1048:FEA_WP_PASS_ICALVOTORRE calvo:396:FEA_WP_PASS_CALVO"
|
|
ADMINS_KO="pabloarias:1047:FEA_WP_PASS_PABLOARIAS josek:1049:FEA_WP_PASS_JOSEK andrey:1087:FEA_WP_PASS_ANDREY"
|
|
|
|
wp() { ssh -o BatchMode=yes "$SRV" "docker exec -u www-data $WPC php wp-content/wp-cli.phar --path=/var/www/html --skip-plugins --skip-themes $*" 2>/dev/null; }
|
|
|
|
# Variante que SI carga los plugins. Hace falta para el paso 3e: sin ella la clase
|
|
# wfConfig de Wordfence no existe y el eval falla en silencio.
|
|
wpp() { ssh -o BatchMode=yes "$SRV" "docker exec -u www-data $WPC php wp-content/wp-cli.phar --path=/var/www/html --skip-themes $*" 2>/dev/null; }
|
|
|
|
echo "=== 1. Rotar la contrasena de los 6 administradores ==="
|
|
for entry in $ADMINS_OK $ADMINS_KO; do
|
|
login=${entry%%:*}; rest=${entry#*:}; id=${rest%%:*}; var=${rest##*:}
|
|
pass="${!var:-}"
|
|
[ -n "$pass" ] || { echo " $login: falta $var en el .env"; exit 1; }
|
|
if [ $DRY -eq 1 ]; then
|
|
echo " [dry-run] rotaria la contrasena de $login (ID $id)"
|
|
else
|
|
ssh -o BatchMode=yes "$SRV" \
|
|
"docker exec -e NEWPASS=\"$pass\" -u www-data $WPC php wp-content/wp-cli.phar --path=/var/www/html --skip-plugins --skip-themes eval 'wp_set_password(getenv(\"NEWPASS\"), $id);'" 2>/dev/null
|
|
echo " $login (ID $id): contrasena rotada"
|
|
fi
|
|
done
|
|
|
|
echo ""
|
|
echo "=== 2. Deshabilitar las tres cuentas del incidente #183 ==="
|
|
echo " (doble cinturon: se les quita el rol Y el mu-plugin fea-security-blocked-users.php"
|
|
echo " bloquea wp_authenticate_user y allow_password_reset para estos mismos IDs)"
|
|
for entry in $ADMINS_KO; do
|
|
login=${entry%%:*}; rest=${entry#*:}; id=${rest%%:*}
|
|
if [ $DRY -eq 1 ]; then
|
|
echo " [dry-run] quitaria el rol administrator a $login (ID $id)"
|
|
else
|
|
wp user remove-role "$id" administrator >/dev/null || true
|
|
echo " $login (ID $id): rol administrator retirado"
|
|
fi
|
|
done
|
|
|
|
echo ""
|
|
echo "=== 3. Limpiar los cron huerfanos de plugins ya desinstalados ==="
|
|
for hook in wpseo-reindex wpseo_permalink_structure_check wppb_review_check cozmos_wppb_plugin_optin_sync; do
|
|
if [ $DRY -eq 1 ]; then
|
|
echo " [dry-run] borraria el evento $hook"
|
|
else
|
|
out=$(wp cron event delete "$hook" 2>&1 || true)
|
|
echo " $hook: ${out:-sin eventos}"
|
|
fi
|
|
done
|
|
|
|
echo ""
|
|
echo "=== 3b. Retirar la inyeccion de SEO spam del incidente #183 ==="
|
|
# La opcion 'wordfence_core_options' NO es de Wordfence (que ni siquiera esta instalado):
|
|
# es el payload que dejo el atacante, cifrado con un cesar de una letra, con un div oculto
|
|
# (left:-7585px) y enlaces a un casino japones, enganchado a wp_footer. Hoy esta inerte
|
|
# porque quien lo leia era el plugin wp-highlits, en cuarentena. Pero VIAJA EN EL DUMP,
|
|
# asi que cada import lo reintroduce. Se preserva antes de borrar (evidencia del #183).
|
|
SPAMOPT=wordfence_core_options
|
|
if [ $DRY -eq 1 ]; then
|
|
echo " [dry-run] preservaria y borraria la opcion $SPAMOPT"
|
|
else
|
|
ssh -o BatchMode=yes "$SRV" "bash -s" <<REMOTE
|
|
RP=\$(docker inspect mysql-$U --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^MYSQL_ROOT_PASSWORD=//p')
|
|
m() { docker exec mysql-$U mysql --default-character-set=utf8mb4 -uroot -p"\$RP" -N -e "\$1" wordpress 2>/dev/null; }
|
|
EV=/data/feadulta-migracion/evidencia-183; mkdir -p "\$EV"
|
|
m "SELECT option_value FROM wp_options WHERE option_name='$SPAMOPT';" > "\$EV/${SPAMOPT}.\$(date +%Y%m%d%H%M%S).txt"
|
|
m "DELETE FROM wp_options WHERE option_name='$SPAMOPT';"
|
|
n=\$(m "SELECT COUNT(*) FROM wp_options WHERE option_name='$SPAMOPT';")
|
|
echo " $SPAMOPT: preservada y borrada (quedan \$n)"
|
|
REMOTE
|
|
fi
|
|
|
|
echo ""
|
|
echo "=== 3c. Retirar limit-login-attempts-reloaded ==="
|
|
# Decision de Rafa (2-ago): el bloqueo de login lo lleva Wordfence, que hace lo mismo
|
|
# y ademas firewall de aplicacion, escaner de malware y 2FA. Tener los dos = dos
|
|
# contadores compitiendo. El dump trae LLAR en active_plugins, asi que cada import
|
|
# lo revive: por eso se retira aqui y no una sola vez a mano.
|
|
if [ $DRY -eq 1 ]; then
|
|
echo " [dry-run] desactivaria y borraria limit-login-attempts-reloaded"
|
|
echo " [dry-run] limpiaria las opciones limit_login_*"
|
|
else
|
|
wp plugin deactivate limit-login-attempts-reloaded >/dev/null 2>&1 || true
|
|
wp plugin delete limit-login-attempts-reloaded >/dev/null 2>&1 || true
|
|
echo " limit-login-attempts-reloaded: retirado"
|
|
ssh -o BatchMode=yes "$SRV" "bash -s" <<REMOTE
|
|
RP=\$(docker inspect mysql-$U --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^MYSQL_ROOT_PASSWORD=//p')
|
|
docker exec mysql-$U mysql --default-character-set=utf8mb4 -uroot -p"\$RP" -N -e \
|
|
"DELETE FROM wp_options WHERE option_name LIKE 'limit_login%';" wordpress 2>/dev/null
|
|
n=\$(docker exec mysql-$U mysql -uroot -p"\$RP" -N -e "SELECT COUNT(*) FROM wp_options WHERE option_name LIKE 'limit_login%';" wordpress 2>/dev/null)
|
|
echo " opciones limit_login_* restantes: \$n"
|
|
REMOTE
|
|
fi
|
|
|
|
echo ""
|
|
echo "=== 3d. Wordfence presente? ==="
|
|
if [ $DRY -eq 1 ]; then
|
|
echo " [dry-run] comprobaria Wordfence"
|
|
else
|
|
if wp plugin is-installed wordfence 2>/dev/null; then
|
|
wp plugin activate wordfence >/dev/null 2>&1 || true
|
|
echo " wordfence: instalado y activo"
|
|
else
|
|
echo " wordfence: NO instalado -> instalando desde wordpress.org"
|
|
wp plugin install wordfence --activate >/dev/null 2>&1 && echo " wordfence: instalado" || echo " wordfence: FALLO la instalacion, revisar a mano"
|
|
fi
|
|
fi
|
|
|
|
echo ""
|
|
echo "=== 3e. Wordfence NO debe bloquear las application passwords ==="
|
|
# Wordfence trae loginSec_disableApplicationPasswords=1 POR DEFECTO, y con eso
|
|
# wp_is_application_passwords_available() devuelve false: la seccion desaparece del
|
|
# perfil y, lo que importa, la autenticacion por REST falla con 401. Los bots de la
|
|
# carta publican asi (publicabot, de icalvotorre), o sea que con esto en 1 no publican.
|
|
#
|
|
# Detectado el 3-ago despues del cutover, con un 401 real de icalvotorre contra
|
|
# /wp-json/wp/v2/users/me. En produccion nunca paso porque alli Wordfence no estaba
|
|
# instalado; lo introdujimos nosotros al anadirlo.
|
|
#
|
|
# Va aqui y no una sola vez a mano porque el paso 3d reinstala Wordfence si falta, y
|
|
# una instalacion nueva vuelve a ponerlo a 1.
|
|
if [ $DRY -eq 1 ]; then
|
|
echo " [dry-run] pondria loginSec_disableApplicationPasswords a 0"
|
|
else
|
|
# Por heredoc y no por la funcion wp(): esa expande "$*" y se come las comillas
|
|
# del codigo PHP. Y SIN --skip-plugins, o la clase wfConfig no existe.
|
|
ssh -o BatchMode=yes "$SRV" "WPC=$WPC bash -s" <<'REMOTE'
|
|
W="docker exec -u www-data $WPC php wp-content/wp-cli.phar --path=/var/www/html --skip-themes"
|
|
$W eval 'wfConfig::set("loginSec_disableApplicationPasswords", 0);' >/dev/null 2>&1
|
|
v=$($W eval 'echo var_export(wfConfig::get("loginSec_disableApplicationPasswords"), true);' 2>/dev/null)
|
|
echo " loginSec_disableApplicationPasswords = $v (tiene que ser '0')"
|
|
echo " disponibles?: $($W eval '$_SERVER["HTTPS"]="on"; echo wp_is_application_passwords_available() ? "SI" : "NO -- los bots no podran publicar";' 2>/dev/null)"
|
|
echo " application passwords que existen (no se tocan, se coordinan con Inma):"
|
|
$W eval '
|
|
foreach (get_users(["fields" => "ID"]) as $id) {
|
|
foreach (WP_Application_Passwords::get_user_application_passwords($id) as $p) {
|
|
$u = get_user_by("id", $id);
|
|
echo " ", $u->user_login, " / ", $p["name"], " ultimo uso ",
|
|
($p["last_used"] ? date("Y-m-d", $p["last_used"]) : "nunca"), PHP_EOL;
|
|
}
|
|
}' 2>/dev/null
|
|
REMOTE
|
|
fi
|
|
|
|
echo ""
|
|
echo "=== 4. Verificacion ==="
|
|
echo " administradores que quedan:"
|
|
wp user list --role=administrator --fields=ID,user_login,user_email --format=csv | sed 's/^/ /'
|
|
echo " el mu-plugin de bloqueo esta presente?:"
|
|
ssh -o BatchMode=yes "$SRV" "docker exec $WPC test -f /var/www/html/wp-content/mu-plugins/fea-security-blocked-users.php && echo ' SI' || echo ' NO -- FALTA, las cuentas del incidente quedarian solo con el rol retirado'"
|
|
echo ""
|
|
echo " application passwords existentes (NO se tocan: las usan los bots de la carta;"
|
|
echo " su rotacion se coordina con Inma):"
|
|
wp db query "SELECT u.user_login, LENGTH(m.meta_value) AS bytes FROM wp_usermeta m JOIN wp_users u ON u.ID=m.user_id WHERE m.meta_key='_application_passwords';" 2>/dev/null | sed 's/^/ /' || \
|
|
echo " (wp db query no disponible; consultar por SQL directo)"
|