LinuxParty

Inicio desactivadoInicio desactivadoInicio desactivadoInicio desactivadoInicio desactivado
 
#!/usr/bin/env bash
#
# auditor-web-malware.sh
# Auditor de solo lectura para buscar indicios de compromiso en sitios PHP.
# Detecta Joomla, WordPress o funciona en modo generico.
# Inspirado en la guia de LinuxParty sobre Helix Ultimate y SP Page Builder.
#
# No elimina, mueve ni modifica archivos o registros de la base de datos.

set -uo pipefail
export LC_ALL=C

VERSION="1.3.1"
DAYS=30
SINCE=""
ROOT=""
LOG_DIR=""
TOKEN=""
OUTPUT=""
MAX_RESULTS=200
SKIP_DB=0
QUIET=0
COLOR_MODE="auto"
WEB_USERS="apache,www-data,nginx"
CMS_MODE="auto"
CMS="generic"
ALERT_HITS=0
REVIEW_HITS=0
ERRORS=0
MYSQL_CNF=""
SUSPECTS_OUTPUT=""
REVIEW_SCRIPT=""
RUN_STAMP=$(date '+%Y%m%d-%H%M%S')

usage() {
    cat <<'EOF'
Uso:
  sudo ./auditor-web-malware.sh --root /ruta/al/sitio [opciones]

Opciones:
  -r, --root DIR       Directorio raiz del sitio web (obligatorio).
  -d, --days N         PHP modificados en los ultimos N dias (30).
      --since FECHA    Buscar ficheros posteriores a FECHA; ej. 2026-07-15.
  -l, --logs DIR       Directorio de logs Apache/Nginx. Se intenta detectar.
  -t, --token TEXTO    Token, nombre de webshell o texto literal para los logs.
  -o, --output FILE    Informe de salida. Por defecto, en el directorio actual.
  -m, --max N          Maximo de lineas mostradas por prueba (200).
      --web-users LIST Usuarios web separados por coma (apache,www-data,nginx).
      --cms TIPO       auto, joomla, wordpress o generic (auto).
      --skip-db        No analizar la base de datos del CMS.
  -q, --quiet          No mostrar progreso; escribir solamente el informe.
      --color MODO     Colores: auto, always o never (auto).
      --no-color       Equivale a --color never.
  -h, --help           Mostrar esta ayuda.
      --version        Mostrar la version.

Codigos de salida:
  0  Auditoria terminada sin indicios de alta prioridad.
  1  Se encontraron indicios de alta prioridad que deben revisarse.
  2  Error de uso o no se pudo completar una parte esencial.

Ejemplos:
  sudo ./auditor-web-malware.sh -r /var/www/vhosts/dominio/httpdocs
  sudo ./auditor-web-malware.sh -r /var/www/vhosts/dominio/httpdocs \
       --since 2026-07-15 --logs /var/www/vhosts/system/dominio/logs
  sudo CMS_DB_PASSWORD='clave' ./auditor-web-malware.sh -r /srv/wordpress

La variable CMS_DB_PASSWORD, si existe, sustituye la clave leida de
configuration.php o wp-config.php. La clave nunca se imprime en el informe.

El auditor genera junto al informe:
  - Una lista terminada en .sospechosos.txt.
  - Un script autonomo revisar-SITIO-FECHA.sh para examinar con bat o less
    los ficheros sospechosos de esa ejecucion concreta.
EOF
}
die() {
    printf 'ERROR: %s\n' "$*" >&2
    exit 2
}

is_uint() {
    [[ "$1" =~ ^[0-9]+$ ]]
}

while (($#)); do
    case "$1" in
        -r|--root)
            (($# >= 2)) || die "Falta el valor de $1"
            ROOT=$2; shift 2 ;;
        -d|--days)
            (($# >= 2)) || die "Falta el valor de $1"
            DAYS=$2; shift 2 ;;
        --since)
            (($# >= 2)) || die "Falta el valor de $1"
            SINCE=$2; shift 2 ;;
        -l|--logs)
            (($# >= 2)) || die "Falta el valor de $1"
            LOG_DIR=$2; shift 2 ;;
        -t|--token)
            (($# >= 2)) || die "Falta el valor de $1"
            TOKEN=$2; shift 2 ;;
        -o|--output)
            (($# >= 2)) || die "Falta el valor de $1"
            OUTPUT=$2; shift 2 ;;
        -m|--max)
            (($# >= 2)) || die "Falta el valor de $1"
            MAX_RESULTS=$2; shift 2 ;;
        --web-users)
            (($# >= 2)) || die "Falta el valor de $1"
            WEB_USERS=$2; shift 2 ;;
        --cms)
            (($# >= 2)) || die "Falta el valor de $1"
            CMS_MODE=${2,,}; shift 2 ;;
        --skip-db) SKIP_DB=1; shift ;;
        -q|--quiet) QUIET=1; shift ;;
        --color)
            (($# >= 2)) || die "Falta el valor de $1"
            COLOR_MODE=${2,,}; shift 2 ;;
        --no-color) COLOR_MODE="never"; shift ;;
        -h|--help) usage; exit 0 ;;
        --version) printf '%s\n' "$VERSION"; exit 0 ;;
        --) shift; break ;;
        -*) die "Opcion desconocida: $1" ;;
        *)
            [[ -z "$ROOT" ]] || die "Argumento inesperado: $1"
            ROOT=$1; shift ;;
    esac
done

[[ -n "$ROOT" ]] || die "Debes indicar --root /ruta/al/sitio"
[[ -d "$ROOT" ]] || die "No existe el directorio: $ROOT"
is_uint "$DAYS" || die "--days debe ser un numero entero"
is_uint "$MAX_RESULTS" && ((MAX_RESULTS > 0)) || die "--max debe ser mayor que cero"

ROOT=$(readlink -f -- "$ROOT") || die "No se pudo resolver la ruta raiz"
[[ "$ROOT" != "/" ]] || die "No se permite auditar / como raiz. Indica el sitio concreto"
[[ "$CMS_MODE" =~ ^(auto|joomla|wordpress|generic)$ ]] || die "--cms debe ser auto, joomla, wordpress o generic"
[[ "$COLOR_MODE" =~ ^(auto|always|never)$ ]] || die "--color debe ser auto, always o never"

if [[ -n "$SINCE" ]] && ! date -d "$SINCE" '+%F %T' >/dev/null 2>&1; then
    die "Fecha no valida para --since: $SINCE"
fi

if [[ -z "$OUTPUT" ]]; then
    OUTPUT="$PWD/auditoria-web-$(basename "$ROOT")-$RUN_STAMP.log"
fi
OUTPUT_DIR=$(dirname -- "$OUTPUT")
[[ -d "$OUTPUT_DIR" ]] || die "No existe el directorio del informe: $OUTPUT_DIR"
umask 077
: >"$OUTPUT" || die "No se puede escribir el informe: $OUTPUT"
OUTPUT=$(readlink -f -- "$OUTPUT")
if [[ "$OUTPUT" == *.log ]]; then
    SUSPECTS_OUTPUT="${OUTPUT%.log}.sospechosos.txt"
else
    SUSPECTS_OUTPUT="${OUTPUT}.sospechosos.txt"
fi
REVIEW_SCRIPT="$OUTPUT_DIR/revisar-$(basename "$ROOT")-$RUN_STAMP.sh"
: >"$SUSPECTS_OUTPUT" || die "No se puede escribir la lista de sospechosos: $SUSPECTS_OUTPUT"

WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/auditor-web.XXXXXXXX") || die "No se pudo crear el temporal"
chmod 700 "$WORKDIR"
cleanup() {
    if [[ -n "$MYSQL_CNF" && -f "$MYSQL_CNF" ]]; then
        : >"$MYSQL_CNF"
    fi
    rm -rf -- "$WORKDIR"
}
trap cleanup EXIT
# Las señales deben finalizar realmente la auditoria. El trap EXIT se encarga
# después de borrar el temporal y destruir el fichero efimero de MySQL.
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM

# Los colores se aplican solo a la consola. El informe siempre queda en texto
# plano para poder buscar, comparar, enviar o procesar sus resultados.
C_RESET=""
C_BOLD=""
C_DIM=""
C_RED=""
C_YELLOW=""
C_GREEN=""
C_BLUE=""
C_CYAN=""
C_MAGENTA=""
C_WHITE=""

init_colors() {
    local enable=0
    if [[ "$COLOR_MODE" == "always" ]]; then
        enable=1
    elif [[ "$COLOR_MODE" == "auto" && -t 1 && -z "${NO_COLOR:-}" ]]; then
        enable=1
    fi

    if ((enable)); then
        C_RESET=$'\033[0m'
        C_BOLD=$'\033[1m'
        C_DIM=$'\033[2m'
        C_RED=$'\033[1;31m'
        C_YELLOW=$'\033[1;33m'
        C_GREEN=$'\033[1;32m'
        C_BLUE=$'\033[1;34m'
        C_CYAN=$'\033[1;36m'
        C_MAGENTA=$'\033[1;35m'
        C_WHITE=$'\033[0;37m'
    fi
}

init_colors

console_line() {
    local color=$1
    shift
    ((QUIET)) || printf '%b%s%b\n' "$color" "$*" "$C_RESET"
}

say() {
    printf '%s\n' "$*" >>"$OUTPUT"
    ((QUIET)) || printf '%s\n' "$*"
}

say_color() {
    local color=$1
    shift
    printf '%s\n' "$*" >>"$OUTPUT"
    console_line "$color" "$*"
}

progress() {
    console_line "$C_CYAN" "-> $*"
}

section() {
    local rule="================================================================================"
    say ""
    say_color "$C_CYAN" "$rule"
    say_color "${C_BOLD}${C_CYAN}" "$1"
    say_color "$C_CYAN" "$rule"
}

note_error() {
    ERRORS=$((ERRORS + 1))
    say_color "$C_RED" "[ERROR] $*"
}

priority_color() {
    case "$1" in
        ALERTA) printf '%s' "$C_RED" ;;
        REVISAR) printf '%s' "$C_YELLOW" ;;
        INFO) printf '%s' "$C_BLUE" ;;
        *) printf '%s' "$C_RESET" ;;
    esac
}

# Localiza dentro de una coincidencia el nombre de un fichero real bajo ROOT.
# Sirve tanto para salidas grep (fichero:linea:contenido) como para listados find.
result_file_from_line() {
    local line=$1 candidate
    [[ "$line" == *"$ROOT"* ]] || return 1
    candidate="$ROOT${line#*"$ROOT"}"

    while [[ "$candidate" != "$ROOT" && ! -f "$candidate" ]]; do
        if [[ "$candidate" == *:* ]]; then
            candidate=${candidate%:*}
        elif [[ "$candidate" == *" "* ]]; then
            candidate=${candidate% *}
        elif [[ "$candidate" == *" -> "* ]]; then
            candidate=${candidate%% -> *}
        else
            return 1
        fi
    done
    [[ -f "$candidate" ]] || return 1
    printf '%s' "$candidate"
}

# Presenta el contexto en blanco y resalta solo la ruta del fichero.
console_result_line() {
    local line=$1 result_file before after
    if result_file=$(result_file_from_line "$line"); then
        before=${line%%"$result_file"*}
        after=${line#*"$result_file"}
        printf '%b%s%b%b%s%b%s%b\n' \
            "$C_WHITE" "$before" "$C_RESET" \
            "$C_MAGENTA" "$result_file" "$C_RESET" \
            "$after" "$C_RESET"
    else
        printf '%b%s%b\n' "$C_WHITE" "$line" "$C_RESET"
    fi
}

# Añade a la lista de revision los ficheros de ALERTA o REVISAR, sin duplicados.
collect_suspect_files() {
    local file=$1 line result_file
    while IFS= read -r line; do
        if result_file=$(result_file_from_line "$line"); then
            printf '%s\n' "$result_file" >>"$SUSPECTS_OUTPUT"
        fi
    done <"$file"
    sort -u -o "$SUSPECTS_OUTPUT" "$SUSPECTS_OUTPUT"
}

# Crea un revisor autocontenido para los resultados de esta ejecucion.
# Las rutas se insertan escapadas como elementos de un array Bash: nunca se
# evaluan como codigo y los ficheros se abren exclusivamente en modo lectura.
generate_review_script() {
    local item
    {
        cat <<'REVIEW_HEADER'
#!/usr/bin/env bash
#
# Revisor autonomo generado por auditor-web.sh.
# Abre como texto los ficheros sospechosos de una auditoria concreta.
# No ejecuta, modifica ni elimina ninguno de ellos.

set -uo pipefail
export LC_ALL=C

AUDIT_ROOT=
AUDIT_DATE=
AUDIT_REPORT=
FILES=(
REVIEW_HEADER
        while IFS= read -r item || [[ -n "$item" ]]; do
            [[ -n "$item" ]] || continue
            printf '    %q\n' "$item"
        done <"$SUSPECTS_OUTPUT"
        cat <<'REVIEW_BODY'
)

die() {
    printf 'ERROR: %s\n' "$*" >&2
    exit 2
}

choose_viewer() {
    if command -v bat >/dev/null 2>&1; then
        VIEWER="bat"
    elif command -v batcat >/dev/null 2>&1; then
        VIEWER="batcat"
    elif command -v less >/dev/null 2>&1; then
        VIEWER="less"
    else
        die "No se encontro bat, batcat ni less"
    fi
}

show_list() {
    local i state
    printf '\nRevision: %s\n' "$AUDIT_DATE"
    printf 'Raiz auditada: %s\n' "$AUDIT_ROOT"
    printf 'Informe: %s\n' "$AUDIT_REPORT"
    printf 'Ficheros sospechosos: %d\n\n' "${#FILES[@]}"
    for i in "${!FILES[@]}"; do
        [[ -f "${FILES[$i]}" ]] && state="" || state=" [YA NO EXISTE]"
        printf '  %3d) %s%s\n' "$((i + 1))" "${FILES[$i]}" "$state"
    done
    printf '\n'
}

safe_file() {
    local file=$1 resolved
    resolved=$(readlink -f -- "$file" 2>/dev/null || true)
    [[ -n "$resolved" && -f "$resolved" ]] || return 1
    [[ "$resolved" == "$AUDIT_ROOT/"* ]] || return 1
    printf '%s' "$resolved"
}

open_file() {
    local requested=$1 file
    if ! file=$(safe_file "$requested"); then
        printf 'No se abre: no existe, no es regular o esta fuera de la raiz auditada.\n' >&2
        return
    fi
    printf '\nRevisando: %s\n' "$file"
    printf 'Tamaño: %s bytes | Modificado: %s\n\n' \
        "$(stat -c '%s' -- "$file" 2>/dev/null || printf '?')" \
        "$(stat -c '%y' -- "$file" 2>/dev/null || printf '?')"
    if [[ "$VIEWER" == "bat" || "$VIEWER" == "batcat" ]]; then
        "$VIEWER" --paging=always --color=always \
            --style=header,numbers,changes -- "$file"
    else
        LESS='-R -N -S' less -- "$file"
    fi
}

choose_viewer
if ((${#FILES[@]} == 0)); then
    printf 'Esta auditoria no produjo ficheros sospechosos para revisar.\n'
    exit 0
fi

show_list
index=0
while ((index < ${#FILES[@]})); do
    printf '[%d/%d] %s\n' "$((index + 1))" "${#FILES[@]}" "${FILES[$index]}"
    read -r -p "INTRO/n=abrir, s=saltar, a NUM=abrir, l=lista, q=salir: " action || break
    case "$action" in
        ""|n|N)
            open_file "${FILES[$index]}"
            index=$((index + 1))
            ;;
        s|S) index=$((index + 1)) ;;
        l|L) show_list ;;
        q|Q) break ;;
        a\ [0-9]*|A\ [0-9]*)
            number=${action#* }
            if ((number >= 1 && number <= ${#FILES[@]})); then
                open_file "${FILES[$((number - 1))]}"
            else
                printf 'Numero fuera de rango.\n'
            fi
            ;;
        *) printf 'Opcion no reconocida.\n' ;;
    esac
done

printf 'Revision terminada. No se ha modificado ningun fichero.\n'
REVIEW_BODY
    } >"$REVIEW_SCRIPT" || {
        note_error "No se pudo generar el script de revision: $REVIEW_SCRIPT"
        return 1
    }

    # Sustituye exclusivamente las tres asignaciones reservadas de cabecera.
    sed -i \
        -e "s|^AUDIT_ROOT=$|AUDIT_ROOT=$(printf '%q' "$ROOT")|" \
        -e "s|^AUDIT_DATE=$|AUDIT_DATE=$(printf '%q' "$(date --iso-8601=seconds)")|" \
        -e "s|^AUDIT_REPORT=$|AUDIT_REPORT=$(printf '%q' "$OUTPUT")|" \
        "$REVIEW_SCRIPT"
    chmod 700 "$REVIEW_SCRIPT" || {
        note_error "No se pudo marcar como ejecutable: $REVIEW_SCRIPT"
        return 1
    }
    return 0
}

# Muestra un fichero de resultados, limita su salida y actualiza los contadores.
# Prioridad: ALERTA (alta), REVISAR (media), INFO (informativa).
publish_results() {
    local title=$1 priority=$2 file=$3 collect=${4:-1} total color line
    total=$(wc -l <"$file" 2>/dev/null || printf '0')
    total=${total//[[:space:]]/}
    [[ -n "$total" ]] || total=0

    color=$(priority_color "$priority")
    say ""
    say_color "$color" "[$priority] $title"
    say_color "$color" "Coincidencias: $total"
    if ((total > 0)); then
        sed -n "1,${MAX_RESULTS}p" "$file" >>"$OUTPUT"
        if ((total > MAX_RESULTS)); then
            printf '%s\n' "... se omiten $((total - MAX_RESULTS)) resultados (usa --max para ampliar)." >>"$OUTPUT"
        fi
        if ((QUIET == 0)); then
            while IFS= read -r line; do
                console_result_line "$line"
            done < <(sed -n "1,${MAX_RESULTS}p" "$file")
            if ((total > MAX_RESULTS)); then
                console_line "$C_MAGENTA" "... se omiten $((total - MAX_RESULTS)) resultados (usa --max para ampliar)."
            fi
        fi
        case "$priority" in
            ALERTA)
                ALERT_HITS=$((ALERT_HITS + total))
                ((collect)) && collect_suspect_files "$file"
                ;;
            REVISAR)
                REVIEW_HITS=$((REVIEW_HITS + total))
                ((collect)) && collect_suspect_files "$file"
                ;;
        esac
    else
        say_color "$C_GREEN" "Sin coincidencias."
    fi
}

# Recorta lineas enormes/ofuscadas para que el informe siga siendo manejable.
trim_lines() {
    awk '{ if (length($0) > 1200) print substr($0,1,1200) " ...[linea recortada]"; else print }'
}

grep_code() {
    local outfile=$1 regex=$2
    grep -RInE --binary-files=without-match \
        --exclude-dir=.git --exclude-dir=.svn \
        --include='*.php' --include='*.phtml' --include='*.php[0-9]' \
        --include='*.pht' --include='*.phar' --include='*.inc' \
        --include='*.module' --include='*.js' \
        -- "$regex" "$ROOT" 2>/dev/null | trim_lines >"$outfile" || true
}

run_find_recent() {
    local outfile=$1
    if [[ -n "$SINCE" ]]; then
        find "$ROOT" -xdev -type f \
            \( -iname '*.php' -o -iname '*.phtml' -o -iname '*.php[0-9]' -o -iname '*.inc' \) \
            -newermt "$SINCE" -printf '%TY-%Tm-%Td %TH:%TM  %M  %u:%g  %s bytes  %p\n' \
            2>/dev/null | sort -r >"$outfile"
    else
        find "$ROOT" -xdev -type f \
            \( -iname '*.php' -o -iname '*.phtml' -o -iname '*.php[0-9]' -o -iname '*.inc' \) \
            -mtime "-$DAYS" -printf '%TY-%Tm-%Td %TH:%TM  %M  %u:%g  %s bytes  %p\n' \
            2>/dev/null | sort -r >"$outfile"
    fi
}

progress "Preparando auditoria de $ROOT"

if [[ "$CMS_MODE" == "auto" ]]; then
    if [[ -f "$ROOT/configuration.php" && -d "$ROOT/administrator" ]]; then
        CMS="joomla"
    elif [[ -f "$ROOT/wp-config.php" && -d "$ROOT/wp-includes" ]]; then
        CMS="wordpress"
    else
        CMS="generic"
    fi
else
    CMS=$CMS_MODE
fi

say_color "${C_BOLD}${C_CYAN}" "AUDITORIA DE SEGURIDAD WEB - auditor-web-malware.sh v$VERSION"
say "Inicio:        $(date --iso-8601=seconds)"
say "Servidor:      $(hostname -f 2>/dev/null || hostname)"
say "Usuario:       $(id -un) (uid=$(id -u))"
say "Raiz web:      $ROOT"
say "CMS detectado: $CMS"
say "Informe:       $OUTPUT"
say "Periodo:       ${SINCE:+desde $SINCE}${SINCE:-ultimos $DAYS dias}"
say_color "$C_GREEN" "Modo:          SOLO LECTURA; ninguna coincidencia se elimina automaticamente"

if [[ "$CMS" == "joomla" && ! -f "$ROOT/configuration.php" ]]; then
    say_color "$C_YELLOW" "ADVERTENCIA: no se encuentra configuration.php; comprueba la raiz indicada."
elif [[ "$CMS" == "wordpress" && ! -f "$ROOT/wp-config.php" ]]; then
    say_color "$C_YELLOW" "ADVERTENCIA: no se encuentra wp-config.php; comprueba la raiz indicada."
elif [[ "$CMS" == "generic" ]]; then
    say_color "$C_BLUE" "INFO: CMS no reconocido; se aplicaran todas las pruebas genericas de ficheros."
fi

section "1. ARCHIVOS RECIENTES Y NOMBRES SOSPECHOSOS"

F="$WORKDIR/recent"
run_find_recent "$F"
publish_results "PHP y extensiones ejecutables modificados recientemente" "REVISAR" "$F"

F="$WORKDIR/suspicious_names"
find "$ROOT" -xdev -type f \( \
    -iname 'tx_*' -o -iname '*.php.json' -o -iname '*.phtml' -o \
    -iname '*.php[0-9]' -o -iname '*.pht' -o -iname '*.phar' -o -iname '.*.php' -o -iname '*.jpg.php' -o \
    -iname '*.png.php' -o -iname '*.gif.php' -o -iname '*.ico.php' -o \
    -iname '*.txt.php' -o -iname '*.bak.php' -o -iname '*.old.php' -o \
    -iname '*php*.jpg' -o -iname '*php*.png' \
    \) -printf '%TY-%Tm-%Td %TH:%TM  %M  %u:%g  %s bytes  %p\n' \
    2>/dev/null | sort -r >"$F"
publish_results "Extensiones dobles, PHP oculto, tx_* y nombres de alta sospecha" "ALERTA" "$F"

F="$WORKDIR/generic_names"
find "$ROOT" -xdev -type f \( \
    -iname 'cache.php' -o -iname 'about.php' -o -iname 'license.php' -o \
    -iname 'style.php' -o -iname 'index_old.php' -o -iname 'config.old.php' \
    \) -printf '%TY-%Tm-%Td %TH:%TM  %M  %u:%g  %s bytes  %p\n' \
    2>/dev/null | sort -r >"$F"
publish_results "Nombres genericos usados a veces por webshells (pueden ser legitimos)" "REVISAR" "$F"

section "2. PHP EN DIRECTORIOS DONDE NORMALMENTE NO DEBERIA EJECUTARSE"
F="$WORKDIR/php_upload_dirs"
: >"$F"
RISKY_HIGH_DIRS=(images media uploads wp-content/uploads)
for d in "${RISKY_HIGH_DIRS[@]}"; do
    [[ -d "$ROOT/$d" ]] || continue
    find "$ROOT/$d" -xdev -type f \( \
        -iname '*.php' -o -iname '*.phtml' -o -iname '*.php[0-9]' -o -iname '*.pht' -o -iname '*.phar' -o -iname '*.inc' \
        \) -printf '%TY-%Tm-%Td %TH:%TM  %M  %u:%g  %s bytes  %p\n' 2>/dev/null >>"$F"
done
sort -r -o "$F" "$F"
publish_results "Scripts PHP dentro de uploads, images o media" "ALERTA" "$F"

F="$WORKDIR/php_cache_dirs"
: >"$F"
RISKY_REVIEW_DIRS=(tmp cache logs wp-content/cache wp-content/upgrade)
for d in "${RISKY_REVIEW_DIRS[@]}"; do
    [[ -d "$ROOT/$d" ]] || continue
    find "$ROOT/$d" -xdev -type f \( \
        -iname '*.php' -o -iname '*.phtml' -o -iname '*.php[0-9]' -o -iname '*.pht' -o -iname '*.phar' -o -iname '*.inc' \
        \) -printf '%TY-%Tm-%Td %TH:%TM  %M  %u:%g  %s bytes  %p\n' 2>/dev/null >>"$F"
done
sort -r -o "$F" "$F"
publish_results "Scripts PHP dentro de tmp, cache, logs o upgrade" "REVISAR" "$F"

section "3. FIRMAS DE CODIGO PELIGROSO U OFUSCADO"

F="$WORKDIR/high_signal"
HIGH_SIGNAL_REGEX='eval[[:space:]]*\([[:space:]]*(base64_decode|gzinflate|gzuncompress|str_rot13)|assert[[:space:]]*\([[:space:]]*\$_(GET|POST|REQUEST|COOKIE)|((system|exec|shell_exec|passthru|popen)[[:space:]]*\([[:space:]]*\$_(GET|POST|REQUEST|COOKIE))|call_user_func(_array)?[[:space:]]*\([[:space:]]*\$_(GET|POST|REQUEST|COOKIE)|base64_decode[[:space:]]*\([[:space:]]*\$_(GET|POST|REQUEST|COOKIE)'
grep_code "$F" "$HIGH_SIGNAL_REGEX"
publish_results "Combinaciones de alta senal tipicas de webshell" "ALERTA" "$F"

F="$WORKDIR/command_exec"
grep_code "$F" '(system|exec|shell_exec|passthru|proc_open|popen|pcntl_exec)[[:space:]]*\('
publish_results "Funciones capaces de ejecutar comandos" "REVISAR" "$F"

F="$WORKDIR/obfuscation"
grep_code "$F" '(eval|assert|base64_decode|gzinflate|gzuncompress|str_rot13|rawurldecode|urldecode|create_function)[[:space:]]*\(|[A-Za-z0-9+/]{500,}={0,2}'
publish_results "Ofuscacion, evaluacion dinamica o cadenas Base64 muy largas" "REVISAR" "$F"

F="$WORKDIR/uploads"
grep_code "$F" 'move_uploaded_file[[:space:]]*\(|is_uploaded_file[[:space:]]*\('
publish_results "Codigo capaz de recibir/subir ficheros" "REVISAR" "$F"

F="$WORKDIR/direct_db"
grep_code "$F" 'mysqli_connect|new[[:space:]]+mysqli|PDO[[:space:]]*\('
publish_results "Conexiones directas a bases de datos" "REVISAR" "$F"

F="$WORKDIR/js_payloads"
grep_code "$F" '<script|eval[[:space:]]*\(|atob[[:space:]]*\(|fromCharCode[[:space:]]*\(|document\.write[[:space:]]*\('
publish_results "JavaScript inyectado u ofuscado en PHP/JS" "REVISAR" "$F"

F="$WORKDIR/remote_io"
grep_code "$F" '(curl_exec|fsockopen|stream_socket_client)[[:space:]]*\(|(file_get_contents|fopen)[[:space:]]*\([[:space:]]*"https?://|file_put_contents[[:space:]]*\('
publish_results "Descarga remota, sockets o escritura de ficheros" "REVISAR" "$F"

F="$WORKDIR/very_long_lines"
: >"$F"
while IFS= read -r -d '' codefile; do
    awk 'length($0)>10000 {printf "%s:%d: longitud=%d\n", FILENAME, NR, length($0)}' "$codefile" 2>/dev/null || true
done < <(find "$ROOT" -xdev -type f \( -iname '*.php' -o -iname '*.phtml' -o -iname '*.php[0-9]' -o -iname '*.pht' -o -iname '*.phar' -o -iname '*.inc' -o -iname '*.js' \) -print0 2>/dev/null) >"$F"
publish_results "Lineas de codigo mayores de 10.000 caracteres (posible ofuscacion)" "REVISAR" "$F"

section "4. PERMISOS, PROPIETARIOS Y ENLACES SIMBOLICOS"

F="$WORKDIR/exact_777"
find "$ROOT" -xdev \( -type f -o -type d \) -perm 0777 \
    -printf '%M  %u:%g  %p\n' 2>/dev/null >"$F"
publish_results "Archivos o directorios con permisos exactamente 777" "ALERTA" "$F"

F="$WORKDIR/world_writable"
find "$ROOT" -xdev \( -type f -o -type d \) -perm -0002 \
    -printf '%M  %u:%g  %p\n' 2>/dev/null >"$F"
publish_results "Elementos escribibles por cualquier usuario (world-writable)" "REVISAR" "$F"

F="$WORKDIR/web_owner"
: >"$F"
IFS=',' read -r -a WEB_USER_ARRAY <<<"$WEB_USERS"
for webuser in "${WEB_USER_ARRAY[@]}"; do
    webuser=${webuser//[[:space:]]/}
    [[ -n "$webuser" ]] || continue
    if id "$webuser" >/dev/null 2>&1; then
        find "$ROOT" -xdev -user "$webuser" -printf "$webuser  %M  %u:%g  %p\n" \
            2>/dev/null >>"$F"
    fi
done
publish_results "Elementos propiedad de usuarios del servidor web" "REVISAR" "$F"

F="$WORKDIR/external_links"
: >"$F"
while IFS= read -r -d '' link; do
    target=$(readlink -f -- "$link" 2>/dev/null || true)
    if [[ -z "$target" ]]; then
        printf 'ROTO      %s -> %s\n' "$link" "$(readlink -- "$link" 2>/dev/null)" >>"$F"
    elif [[ "$target" != "$ROOT" && "$target" != "$ROOT/"* ]]; then
        printf 'EXTERNO   %s -> %s\n' "$link" "$target" >>"$F"
    fi
done < <(find "$ROOT" -xdev -type l -print0 2>/dev/null)
publish_results "Enlaces simbolicos rotos o que salen de la raiz web" "REVISAR" "$F"

section "5. .HTACCESS, .USER.INI Y CONFIGURACION PHP"
F="$WORKDIR/config_injections"
: >"$F"
while IFS= read -r -d '' cfg; do
    grep -HniE --binary-files=without-match \
        '(auto_prepend_file|auto_append_file|AddHandler.*(php|phtml)|SetHandler.*(php|proxy)|base64|eval[[:space:]]*\()' \
        "$cfg" 2>/dev/null || true
done < <(find "$ROOT" -xdev -type f \( -name '.htaccess' -o -name '.user.ini' -o -name 'php.ini' \) -print0 2>/dev/null) \
    | trim_lines >"$F"
publish_results "Reglas capaces de ejecutar PHP o cargar codigo antes del CMS" "ALERTA" "$F"

F="$WORKDIR/config_redirects"
: >"$F"
while IFS= read -r -d '' cfg; do
    grep -HniE --binary-files=without-match \
        '(RewriteRule.*https?://|RewriteCond.*HTTP_USER_AGENT|php_value|php_flag)' \
        "$cfg" 2>/dev/null || true
done < <(find "$ROOT" -xdev -type f \( -name '.htaccess' -o -name '.user.ini' -o -name 'php.ini' \) -print0 2>/dev/null) \
    | trim_lines >"$F"
publish_results "Redirecciones o reglas condicionales que deben verificarse" "REVISAR" "$F"

F="$WORKDIR/all_htaccess"
find "$ROOT" -xdev -type f \( -name '.htaccess' -o -name '.user.ini' -o -name 'php.ini' \) \
    -printf '%TY-%Tm-%Td %TH:%TM  %M  %u:%g  %s bytes  %p\n' 2>/dev/null | sort -r >"$F"
publish_results "Inventario de .htaccess, .user.ini y php.ini para revision manual" "INFO" "$F"

section "6. LOGS DE APACHE/NGINX"

LOG_DIRS=()
if [[ -n "$LOG_DIR" ]]; then
    [[ -d "$LOG_DIR" ]] && LOG_DIRS+=("$(readlink -f -- "$LOG_DIR")") || note_error "No existe --logs $LOG_DIR"
else
    parent=$(dirname -- "$ROOT")
    domain=$(basename -- "$parent")
    [[ -d "$parent/logs" ]] && LOG_DIRS+=("$parent/logs")
    [[ -d "/var/www/vhosts/system/$domain/logs" ]] && LOG_DIRS+=("/var/www/vhosts/system/$domain/logs")
    [[ -d "$ROOT/logs" ]] && LOG_DIRS+=("$ROOT/logs")
fi

F="$WORKDIR/access_logs"
: >"$F"
LOG_REGEX='tx_|php\.json|\.phtml|cmd=|shell|webshell|base64|com_sppagebuilder|sppagebuilder|helixultimate|option=com_ajax|wp-content/uploads/[^? ]+\.php|wp-vcd|eval-stdin|wp-file-manager'
for logdir in "${LOG_DIRS[@]}"; do
    while IFS= read -r -d '' logfile; do
        zgrep -HniE -- "$LOG_REGEX" "$logfile" 2>/dev/null || true
        if [[ -n "$TOKEN" ]]; then
            zgrep -HniF -- "$TOKEN" "$logfile" 2>/dev/null || true
        fi
    done < <(find "$logdir" -maxdepth 2 -type f \( -iname 'access*' -o -iname '*access*log*' -o -iname 'proxy_access*' \) -print0 2>/dev/null)
done | trim_lines >"$F"
if ((${#LOG_DIRS[@]} == 0)); then
    say_color "$C_YELLOW" "No se detecto un directorio de logs. Repite con --logs /ruta/a/logs."
fi
# Estas coincidencias pertenecen a los logs, no a los ficheros solicitados en
# cada URL. Se muestran y contabilizan, pero no deben recorrerse línea a línea
# ni añadirse a la lista de archivos PHP que abrirá el revisor autónomo.
publish_results "Peticiones relacionadas con webshells, payloads, Helix o SP Page Builder" "REVISAR" "$F" 0

section "7. TAREAS PROGRAMADAS Y PERSISTENCIA"

F="$WORKDIR/cron_suspicious"
: >"$F"
for crondir in /etc/crontab /etc/cron.d /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly /var/spool/cron /var/spool/cron/crontabs; do
    [[ -e "$crondir" ]] || continue
    if [[ -f "$crondir" ]]; then
        grep -HniE '(curl|wget|php|python|perl|base64|/tmp/|/dev/shm|nc[[:space:]]|bash[[:space:]]+-c)' "$crondir" 2>/dev/null || true
    elif [[ -d "$crondir" ]]; then
        grep -RInE --binary-files=without-match \
            '(curl|wget|php|python|perl|base64|/tmp/|/dev/shm|nc[[:space:]]|bash[[:space:]]+-c)' \
            "$crondir" 2>/dev/null || true
    fi
done | trim_lines >"$F"
publish_results "Cron con descargas, interpretes o ejecucion desde temporales" "REVISAR" "$F"

F="$WORKDIR/user_cron"
crontab -l >"$F" 2>/dev/null || : >"$F"
publish_results "Crontab del usuario que ejecuta la auditoria ($(id -un))" "INFO" "$F"

# Extrae una variable o un define() literal de un fichero PHP mediante tokens.
# El fichero potencialmente comprometido se lee como texto: NO se ejecuta.
php_config_value() {
    local config=$1 kind=$2 property=$3
    [[ -n "${PHP_BIN:-}" ]] || return 1
    "$PHP_BIN" -r '
        $code = @file_get_contents($argv[1]);
        if ($code === false) exit(1);
        $tokens = token_get_all($code);
        $kind = $argv[2]; $wanted = $argv[3];
        $n = count($tokens);
        $decode = function($token) {
            $s=$token; $q=$s[0]; $s=substr($s,1,-1);
            if ($q === "\"") return stripcslashes($s);
            return str_replace(["\\\\", "\\".chr(39)], ["\\", chr(39)], $s);
        };
        for ($i=0; $i<$n; $i++) {
            if ($kind === "variable") {
                if (!is_array($tokens[$i]) || $tokens[$i][0] !== T_VARIABLE || $tokens[$i][1] !== "$".$wanted) continue;
                for ($j=$i+1; $j<$n && $j<$i+12; $j++) {
                    if (is_array($tokens[$j]) && $tokens[$j][0] === T_CONSTANT_ENCAPSED_STRING) {
                        echo $decode($tokens[$j][1]); exit(0);
                    }
                }
            } elseif ($kind === "define") {
                if (!is_array($tokens[$i]) || $tokens[$i][0] !== T_STRING || strtolower($tokens[$i][1]) !== "define") continue;
                $strings=[];
                for ($j=$i+1; $j<$n && $j<$i+20; $j++) {
                    if (is_array($tokens[$j]) && $tokens[$j][0] === T_CONSTANT_ENCAPSED_STRING) $strings[]=$decode($tokens[$j][1]);
                    if ($tokens[$j] === ";") break;
                }
                if (count($strings)>=2 && $strings[0] === $wanted) { echo $strings[1]; exit(0); }
            }
        }
        exit(1);
    ' "$config" "$kind" "$property" 2>/dev/null
}

section "8. VERSIONES LOCALES DEL CMS, EXTENSIONES, PLUGINS Y TEMAS"
F="$WORKDIR/versions"
: >"$F"
PHP_BIN=$(command -v php 2>/dev/null || true)
if [[ -z "$PHP_BIN" ]]; then
    for candidate in /opt/plesk/php/*/bin/php /usr/local/bin/php /usr/bin/php; do
        [[ -x "$candidate" ]] && PHP_BIN=$candidate
    done
fi
if [[ "$CMS" == "joomla" ]]; then
    for manifest in \
        "$ROOT/administrator/manifests/files/joomla.xml" \
        "$ROOT/plugins/system/helixultimate/helixultimate.xml" \
        "$ROOT/administrator/components/com_sppagebuilder/sppagebuilder.xml" \
        "$ROOT/components/com_sppagebuilder/sppagebuilder.xml"; do
        [[ -f "$manifest" ]] || continue
        version=$(sed -n 's:.*<version[^>]*>[[:space:]]*\([^<]*\)[[:space:]]*</version>.*:\1:p' "$manifest" | head -n 1)
        printf '%s  version=%s\n' "$manifest" "${version:-no detectada}" >>"$F"
    done
    while IFS= read -r -d '' manifest; do
        version=$(sed -n 's:.*<version[^>]*>[[:space:]]*\([^<]*\)[[:space:]]*</version>.*:\1:p' "$manifest" | head -n 1)
        printf '%s  version=%s\n' "$manifest" "${version:-no detectada}" >>"$F"
    done < <(find "$ROOT/templates" -mindepth 2 -maxdepth 2 -type f -name 'templateDetails.xml' -print0 2>/dev/null)
elif [[ "$CMS" == "wordpress" ]]; then
    if [[ -f "$ROOT/wp-includes/version.php" ]]; then
        version=$(php_config_value "$ROOT/wp-includes/version.php" variable wp_version || true)
        if [[ -z "$version" ]]; then
            version=$(sed -nE "s/^[[:space:]]*\\\$wp_version[[:space:]]*=[[:space:]]*[\"']([^\"']+)[\"'].*/\\1/p" "$ROOT/wp-includes/version.php" | head -n 1)
        fi
        printf 'WordPress core  version=%s\n' "${version:-no detectada}" >>"$F"
    fi
    while IFS= read -r -d '' plugin_file; do
        plugin_name=$(sed -nE 's/^[[:space:]]*\*?[[:space:]]*Plugin Name:[[:space:]]*(.*)$/\1/ip' "$plugin_file" | head -n 1)
        plugin_version=$(sed -nE 's/^[[:space:]]*\*?[[:space:]]*Version:[[:space:]]*(.*)$/\1/ip' "$plugin_file" | head -n 1)
        [[ -n "$plugin_name" ]] && printf 'Plugin: %s  version=%s  fichero=%s\n' "$plugin_name" "${plugin_version:-no detectada}" "$plugin_file" >>"$F"
    done < <(grep -RIlZ --include='*.php' -m1 'Plugin Name:' "$ROOT/wp-content/plugins" 2>/dev/null || true)
    while IFS= read -r -d '' style_file; do
        theme_name=$(sed -nE 's/^[[:space:]]*Theme Name:[[:space:]]*(.*)$/\1/ip' "$style_file" | head -n 1)
        theme_version=$(sed -nE 's/^[[:space:]]*Version:[[:space:]]*(.*)$/\1/ip' "$style_file" | head -n 1)
        [[ -n "$theme_name" ]] && printf 'Tema: %s  version=%s  fichero=%s\n' "$theme_name" "${theme_version:-no detectada}" "$style_file" >>"$F"
    done < <(find "$ROOT/wp-content/themes" -mindepth 2 -maxdepth 2 -type f -name style.css -print0 2>/dev/null)
fi
sort -u -o "$F" "$F"
publish_results "Versiones detectadas; comparar con las corregidas del fabricante" "INFO" "$F"

mysql_escape_option() {
    local value=$1
    value=${value//\\/\\\\}
    value=${value//\"/\\\"}
    printf '%s' "$value"
}

db_query_to_file() {
    local outfile=$1 sql=$2
    if ! "$MYSQL_BIN" --defaults-extra-file="$MYSQL_CNF" --batch --raw --skip-column-names \
        --default-character-set=utf8mb4 "$DB_NAME" -e "$sql" >"$outfile" 2>"$outfile.err"; then
        note_error "Consulta SQL fallida: $(tr '\n' ' ' <"$outfile.err" | cut -c1-300)"
        : >"$outfile"
        return 1
    fi
}

section "9. BASE DE DATOS DEL CMS"
CONFIG=""
[[ "$CMS" == "joomla" ]] && CONFIG="$ROOT/configuration.php"
[[ "$CMS" == "wordpress" ]] && CONFIG="$ROOT/wp-config.php"
if ((SKIP_DB)); then
    say "Analisis SQL omitido por --skip-db."
elif [[ "$CMS" == "generic" ]]; then
    say "Analisis SQL omitido: CMS generico, sin esquema de tablas conocido."
elif [[ -z "$CONFIG" || ! -f "$CONFIG" ]]; then
    say "Analisis SQL omitido: falta el fichero de configuracion del CMS."
elif [[ -z "$PHP_BIN" ]]; then
    say "Analisis SQL omitido: PHP CLI no esta instalado ni se encontro en /opt/plesk/php."
else
    MYSQL_BIN=$(command -v mariadb 2>/dev/null || command -v mysql 2>/dev/null || true)
    if [[ -z "$MYSQL_BIN" ]]; then
        say "Analisis SQL omitido: no se encontro el cliente mariadb/mysql."
    else
        if [[ "$CMS" == "joomla" ]]; then
            DB_TYPE=$(php_config_value "$CONFIG" variable dbtype || true)
            DB_HOST=${CMS_DB_HOST:-$(php_config_value "$CONFIG" variable host || true)}
            DB_USER=${CMS_DB_USER:-$(php_config_value "$CONFIG" variable user || true)}
            DB_PASS=${CMS_DB_PASSWORD:-${JOOMLA_DB_PASSWORD:-$(php_config_value "$CONFIG" variable password || true)}}
            DB_NAME=${CMS_DB_NAME:-$(php_config_value "$CONFIG" variable db || true)}
            DB_PREFIX=${CMS_DB_PREFIX:-$(php_config_value "$CONFIG" variable dbprefix || true)}
        else
            DB_TYPE="mysqli"
            DB_HOST=${CMS_DB_HOST:-$(php_config_value "$CONFIG" define DB_HOST || true)}
            DB_USER=${CMS_DB_USER:-$(php_config_value "$CONFIG" define DB_USER || true)}
            DB_PASS=${CMS_DB_PASSWORD:-$(php_config_value "$CONFIG" define DB_PASSWORD || true)}
            DB_NAME=${CMS_DB_NAME:-$(php_config_value "$CONFIG" define DB_NAME || true)}
            DB_PREFIX=${CMS_DB_PREFIX:-$(php_config_value "$CONFIG" variable table_prefix || true)}
        fi

        if [[ -n "$DB_TYPE" && "$DB_TYPE" != "mysqli" && "$DB_TYPE" != "mysql" ]]; then
            say "Analisis SQL omitido: el tipo de base de datos es $DB_TYPE, no MySQL/MariaDB."
        elif [[ ! "$DB_PREFIX" =~ ^[A-Za-z0-9_]+$ || -z "$DB_NAME" || -z "$DB_USER" ]]; then
            say "Analisis SQL omitido: no se pudieron leer credenciales/prefijo literales sin ejecutar la configuracion."
            say "Puedes indicar CMS_DB_HOST, CMS_DB_USER, CMS_DB_PASSWORD, CMS_DB_NAME y CMS_DB_PREFIX."
        else
            MYSQL_CNF="$WORKDIR/mysql-client.cnf"
            umask 077
            DB_PORT=""
            DB_SOCKET=""
            if [[ "$DB_HOST" =~ ^([^:]+):([0-9]+)$ ]]; then
                DB_HOST=${BASH_REMATCH[1]}
                DB_PORT=${BASH_REMATCH[2]}
            elif [[ "$DB_HOST" =~ ^([^:]+):(/.+)$ ]]; then
                DB_HOST=${BASH_REMATCH[1]}
                DB_SOCKET=${BASH_REMATCH[2]}
            fi
            {
                printf '[client]\n'
                printf 'user="%s"\n' "$(mysql_escape_option "$DB_USER")"
                printf 'password="%s"\n' "$(mysql_escape_option "$DB_PASS")"
                printf 'host="%s"\n' "$(mysql_escape_option "${DB_HOST:-localhost}")"
                [[ -n "$DB_PORT" ]] && printf 'port=%s\n' "$DB_PORT"
                [[ -n "$DB_SOCKET" ]] && printf 'socket="%s"\n' "$(mysql_escape_option "$DB_SOCKET")"
            } >"$MYSQL_CNF"
            chmod 600 "$MYSQL_CNF"

            F="$WORKDIR/db_test"
            if ! db_query_to_file "$F" 'SELECT 1 AS conexion;'; then
                say "No se pudo conectar a la base de datos; el resto de SQL se omite."
            else
                say_color "$C_GREEN" "Conexion SQL de solo lectura establecida con la base '$DB_NAME' y prefijo '$DB_PREFIX'."

                if [[ "$CMS" == "joomla" ]]; then
                    F="$WORKDIR/db_users"
                    db_query_to_file "$F" "SELECT u.id,u.name,u.username,u.email,u.registerDate,COALESCE(GROUP_CONCAT(g.title ORDER BY g.title SEPARATOR ','),'sin grupo') AS grupos FROM ${DB_PREFIX}users u LEFT JOIN ${DB_PREFIX}user_usergroup_map m ON m.user_id=u.id LEFT JOIN ${DB_PREFIX}usergroups g ON g.id=m.group_id GROUP BY u.id,u.name,u.username,u.email,u.registerDate ORDER BY u.id DESC;" || true
                    publish_results "Usuarios Joomla, del mas reciente al mas antiguo" "INFO" "$F"

                    F="$WORKDIR/db_admins"
                    db_query_to_file "$F" "SELECT u.id,u.name,u.username,u.email,u.registerDate,g.id AS group_id,g.title FROM ${DB_PREFIX}users u JOIN ${DB_PREFIX}user_usergroup_map m ON m.user_id=u.id JOIN ${DB_PREFIX}usergroups g ON g.id=m.group_id WHERE g.id=8 OR LOWER(g.title) IN ('super users','superusuarios','super utilisateurs') ORDER BY u.id DESC;" || true
                    publish_results "Super Users de Joomla (verificar uno por uno)" "INFO" "$F"

                    F="$WORKDIR/db_menu_bad"
                    db_query_to_file "$F" "SELECT id,title,LENGTH(params) AS longitud,LEFT(REPLACE(REPLACE(params,CHAR(10),' '),CHAR(13),' '),1000) AS muestra FROM ${DB_PREFIX}menu WHERE params REGEXP '<script|eval[[:space:]]*\\\\(|atob[[:space:]]*\\\\(|fromCharCode[[:space:]]*\\\\(|document\\\\.write[[:space:]]*\\\\(' ORDER BY LENGTH(params) DESC;" || true
                    publish_results "Parametros de menu Joomla con scripts u ofuscacion" "ALERTA" "$F"

                    F="$WORKDIR/db_menu_review"
                    db_query_to_file "$F" "SELECT id,title,LENGTH(params) AS longitud,LEFT(params,1000) AS muestra FROM ${DB_PREFIX}menu WHERE params LIKE '%http://%' OR params LIKE '%https://%' OR LENGTH(params)>10000 ORDER BY LENGTH(params) DESC LIMIT 100;" || true
                    publish_results "Parametros de menu con URL o tamano anormal" "REVISAR" "$F"

                    F="$WORKDIR/db_templates_bad"
                    db_query_to_file "$F" "SELECT id,title,template,LENGTH(params) AS longitud,LEFT(REPLACE(REPLACE(params,CHAR(10),' '),CHAR(13),' '),1000) AS muestra FROM ${DB_PREFIX}template_styles WHERE params REGEXP '<script|eval[[:space:]]*\\\\(|atob[[:space:]]*\\\\(|fromCharCode[[:space:]]*\\\\(|document\\\\.write[[:space:]]*\\\\(' OR LENGTH(params)>10000 ORDER BY LENGTH(params) DESC;" || true
                    publish_results "Estilos de plantilla con payload o parametros anormalmente grandes" "ALERTA" "$F"

                    F="$WORKDIR/db_modules_bad"
                    db_query_to_file "$F" "SELECT id,title,module,LENGTH(params) AS params_len,LENGTH(content) AS content_len,LEFT(REPLACE(REPLACE(CONCAT(params,' ',content),CHAR(10),' '),CHAR(13),' '),1000) AS muestra FROM ${DB_PREFIX}modules WHERE CONCAT(params,' ',content) REGEXP '<script|eval[[:space:]]*\\\\(|atob[[:space:]]*\\\\(|fromCharCode[[:space:]]*\\\\(|document\\\\.write[[:space:]]*\\\\(' OR LENGTH(params)>10000 OR LENGTH(content)>50000 ORDER BY GREATEST(LENGTH(params),LENGTH(content)) DESC;" || true
                    publish_results "Modulos con scripts, ofuscacion o contenido muy grande" "ALERTA" "$F"
                else
                    F="$WORKDIR/db_users"
                    db_query_to_file "$F" "SELECT ID,user_login,user_email,user_registered,display_name FROM ${DB_PREFIX}users ORDER BY ID DESC;" || true
                    publish_results "Usuarios WordPress, del mas reciente al mas antiguo" "INFO" "$F"

                    F="$WORKDIR/db_admins"
                    db_query_to_file "$F" "SELECT u.ID,u.user_login,u.user_email,u.user_registered,um.meta_key,um.meta_value AS capacidades FROM ${DB_PREFIX}users u JOIN ${DB_PREFIX}usermeta um ON um.user_id=u.ID WHERE um.meta_key LIKE '${DB_PREFIX}%capabilities' AND um.meta_value LIKE '%administrator%' ORDER BY u.ID DESC;" || true
                    publish_results "Administradores WordPress (verificar uno por uno)" "INFO" "$F"

                    F="$WORKDIR/db_wp_options_bad"
                    db_query_to_file "$F" "SELECT option_id,option_name,LENGTH(option_value) AS longitud,LEFT(REPLACE(REPLACE(option_value,CHAR(10),' '),CHAR(13),' '),1000) AS muestra FROM ${DB_PREFIX}options WHERE option_value REGEXP '<script|eval[[:space:]]*\\\\(|atob[[:space:]]*\\\\(|fromCharCode[[:space:]]*\\\\(|document\\\\.write[[:space:]]*\\\\(|base64_decode[[:space:]]*\\\\(' ORDER BY LENGTH(option_value) DESC;" || true
                    publish_results "wp_options con scripts, evaluacion u ofuscacion" "ALERTA" "$F"

                    F="$WORKDIR/db_wp_options_large"
                    db_query_to_file "$F" "SELECT option_id,option_name,autoload,LENGTH(option_value) AS longitud,LEFT(REPLACE(REPLACE(option_value,CHAR(10),' '),CHAR(13),' '),500) AS muestra FROM ${DB_PREFIX}options WHERE LENGTH(option_value)>10000 ORDER BY LENGTH(option_value) DESC LIMIT 100;" || true
                    publish_results "Opciones WordPress grandes que merecen revision" "REVISAR" "$F"

                    F="$WORKDIR/db_wp_key_options"
                    db_query_to_file "$F" "SELECT option_name,LEFT(REPLACE(REPLACE(option_value,CHAR(10),' '),CHAR(13),' '),1500) AS valor FROM ${DB_PREFIX}options WHERE option_name IN ('siteurl','home','admin_email','default_role','active_plugins');" || true
                    publish_results "URL, correo administrador, rol por defecto y plugins activos" "INFO" "$F"

                    F="$WORKDIR/db_wp_posts_bad"
                    db_query_to_file "$F" "SELECT ID,post_type,post_status,post_title,LENGTH(post_content) AS longitud,LEFT(REPLACE(REPLACE(post_content,CHAR(10),' '),CHAR(13),' '),1000) AS muestra FROM ${DB_PREFIX}posts WHERE post_content REGEXP 'eval[[:space:]]*\\\\(|atob[[:space:]]*\\\\(|fromCharCode[[:space:]]*\\\\(|document\\\\.write[[:space:]]*\\\\(' ORDER BY ID DESC LIMIT 200;" || true
                    publish_results "Entradas/paginas WordPress con JavaScript ofuscado" "REVISAR" "$F"
                fi
            fi
        fi
    fi
fi

section "10. PASOS MANUALES IMPRESCINDIBLES"
say_color "$C_YELLOW" "[ ] Confirmar cada administrador/Super User y eliminar solo los no reconocidos."
say_color "$C_YELLOW" "[ ] Comparar los archivos sospechosos con una copia limpia de la misma version."
say_color "$C_YELLOW" "[ ] Revisar las lineas de access_log alrededor del primer acceso sospechoso."
if [[ "$CMS" == "joomla" ]]; then
    say_color "$C_YELLOW" "[ ] Actualizar Joomla, Helix Ultimate/Helix3, SP Page Builder y extensiones."
elif [[ "$CMS" == "wordpress" ]]; then
    say_color "$C_YELLOW" "[ ] Actualizar WordPress, plugins y temas; eliminar los que no se utilicen."
else
    say_color "$C_YELLOW" "[ ] Actualizar el CMS/framework y todas sus extensiones o dependencias."
fi
say_color "$C_YELLOW" "[ ] Despues de limpiar: cambiar CMS, MySQL, FTP/SFTP, SSH, panel y API keys."
say_color "$C_YELLOW" "[ ] Revocar sesiones y comprobar crons, usuarios del sistema y claves SSH."
say_color "$C_YELLOW" "[ ] Conservar una copia forense antes de borrar nada si puede haber investigacion."

section "RESUMEN"
generate_review_script
say "Fin:                         $(date --iso-8601=seconds)"
if ((ALERT_HITS > 0)); then
    say_color "$C_RED" "Coincidencias ALERTA:         $ALERT_HITS"
else
    say_color "$C_GREEN" "Coincidencias ALERTA:         $ALERT_HITS"
fi
if ((REVIEW_HITS > 0)); then
    say_color "$C_YELLOW" "Coincidencias para REVISAR:   $REVIEW_HITS"
else
    say_color "$C_GREEN" "Coincidencias para REVISAR:   $REVIEW_HITS"
fi
if ((ERRORS > 0)); then
    say_color "$C_RED" "Errores parciales:             $ERRORS"
else
    say_color "$C_GREEN" "Errores parciales:             $ERRORS"
fi
say_color "$C_CYAN" "Informe completo:              $OUTPUT"
say_color "$C_CYAN" "Lista de ficheros sospechosos: $SUSPECTS_OUTPUT"
if [[ -x "$REVIEW_SCRIPT" ]]; then
    say_color "$C_GREEN" "Script de revision generado:  $REVIEW_SCRIPT"
    say_color "$C_GREEN" "Ejecutar revision:            $REVIEW_SCRIPT"
fi
say ""
say_color "$C_MAGENTA" "IMPORTANTE: una coincidencia no demuestra por si sola que exista malware."
say_color "$C_MAGENTA" "La prioridad aumenta cuando coinciden fecha reciente, ubicacion impropia,"
say_color "$C_MAGENTA" "nombre anomalo, ofuscacion y una peticion correlacionada en access_log."

if ((ERRORS > 0)) && ((ALERT_HITS == 0)); then
    exit 2
elif ((ALERT_HITS > 0)); then
    exit 1
fi
exit 0

No estás registrado para postear comentarios



Redes:



   

 

Suscribete / Newsletter

Suscribete a nuestras Newsletter y periódicamente recibirás un resumen de las noticias publicadas.

Donar a LinuxParty

 

Tutorial de Linux

Top 15 artículos por Fecha

Viendo artículos de: Junio de 2026

Filtro por Categorías