#!/bin/sh # SecuryBlack: auditoría rápida de seguridad de un VPS Linux. # # Solo lee: no cambia nada en el servidor ni envía nada a ningún sitio. # Léelo antes de ejecutarlo: # curl -fsSL https://securyblack.com/audit.sh -o audit.sh && less audit.sh && sudo sh audit.sh # Idioma: según $LANG, o fuerza uno con --es / --en (por tubería: | sudo sh -s -- --en). # # Las reglas y su gravedad son las de FerroSentry, el agente open source que # las repasa cada hora: https://github.com/securyblack/ferro-sentry set -u case "${LC_ALL:-${LANG:-}}" in es*) ES=1 ;; *) ES= ;; esac for a in "$@"; do case "$a" in --es) ES=1 ;; --en) ES= ;; esac done t() { if [ -n "$ES" ]; then printf '%s' "$1"; else printf '%s' "$2"; fi; } have() { command -v "$1" >/dev/null 2>&1; } if [ -t 1 ]; then RED=$(printf '\033[31m'); YEL=$(printf '\033[33m'); GRN=$(printf '\033[32m') DIM=$(printf '\033[2m'); BLD=$(printf '\033[1m'); RST=$(printf '\033[0m') else RED= YEL= GRN= DIM= BLD= RST= fi ROOT= [ "$(id -u)" = 0 ] && ROOT=1 SCORE=100 NC=0 NH=0 NM=0 NL=0 SKIPPED=0 # bad bad() { case "$1" in critical) SCORE=$((SCORE - 30)); NC=$((NC + 1)); lab=$(t CRÍTICA CRITICAL); col=$RED ;; high) SCORE=$((SCORE - 15)); NH=$((NH + 1)); lab=$(t ALTA HIGH); col=$RED ;; medium) SCORE=$((SCORE - 7)); NM=$((NM + 1)); lab=$(t MEDIA MEDIUM); col=$YEL ;; *) SCORE=$((SCORE - 2)); NL=$((NL + 1)); lab=$(t BAJA LOW); col=$DIM ;; esac printf ' %s✗ %-9s%s %s\n' "$col" "$lab" "$RST" "$2" printf ' %s%s%s\n' "$DIM" "$3" "$RST" } ok() { printf ' %s✓%s %s\n' "$GRN" "$RST" "$1"; } skip() { SKIPPED=$((SKIPPED + 1)); printf ' %s– %s%s\n' "$DIM" "$1" "$RST"; } section() { printf '\n%s%s%s\n' "$BLD" "$1" "$RST"; } OS=$( (. /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-}") ) printf '\n%sSecuryBlack · %s%s\n' "$BLD" "$(t 'Auditoría rápida del VPS' 'Quick VPS audit')" "$RST" printf '%s%s · %s · %s%s\n' "$DIM" "$(hostname 2>/dev/null)" "${OS:-Linux}" "$(date '+%Y-%m-%d %H:%M')" "$RST" [ -z "$ROOT" ] && printf '%s%s%s\n' "$YEL" "$(t 'Sin root se saltan varias comprobaciones. Para la nota completa, ejecútalo con sudo.' 'Without root some checks are skipped. Run it with sudo for the full score.')" "$RST" # ── SSH ───────────────────────────────────────────────────────────────────── section SSH if [ -f /etc/ssh/sshd_config ]; then # Con root, la configuración efectiva; sin root, el primer valor de cada # clave en los ficheros (así resuelve sshd), hasta el primer Match. SSHD= [ -n "$ROOT" ] && have sshd && SSHD=$(sshd -T 2>/dev/null) if [ -z "$SSHD" ]; then SSHD=$(cat /etc/ssh/sshd_config.d/*.conf /etc/ssh/sshd_config 2>/dev/null | sed 's/#.*//' | awk 'tolower($1) == "match" { exit } NF >= 2 { k = tolower($1); if (!(k in s)) { s[k] = 1; print k, $2 } }') fi sshv() { printf '%s\n' "$SSHD" | awk -v k="$1" 'tolower($1) == k { print tolower($2); exit }'; } v=$(sshv permitrootlogin) if [ "$v" = yes ]; then bad high "$(t 'SSH deja entrar como root' 'SSH allows root login')" \ "$(t 'PermitRootLogin prohibit-password (o no) en /etc/ssh/sshd_config y reinicia ssh' 'Set PermitRootLogin prohibit-password (or no) in /etc/ssh/sshd_config and restart ssh')" else ok "$(t 'Root no puede entrar con contraseña' 'Root cannot log in with a password')"; fi v=$(sshv passwordauthentication) if [ "${v:-yes}" = yes ]; then bad medium "$(t 'SSH acepta contraseñas' 'SSH accepts passwords')" \ "$(t 'Con tu clave ya funcionando, PasswordAuthentication no' 'Once your key works, set PasswordAuthentication no')" else ok "$(t 'SSH solo acepta claves' 'SSH only accepts keys')"; fi v=$(sshv maxauthtries) if [ "${v:-6}" -gt 4 ] 2>/dev/null; then bad low "$(t "MaxAuthTries en ${v:-6}: demasiados intentos por conexión" "MaxAuthTries is ${v:-6}: too many attempts per connection")" \ "$(t 'MaxAuthTries 3' 'Set MaxAuthTries 3')" else ok "MaxAuthTries ${v}"; fi [ "$(sshv x11forwarding)" = yes ] && bad low "$(t 'Reenvío X11 activado' 'X11 forwarding enabled')" "$(t 'X11Forwarding no, salvo que lo uses' 'Set X11Forwarding no unless you use it')" [ "$(sshv port)" = 22 ] && bad low "$(t 'SSH en el puerto 22, el primero que prueban los bots' 'SSH on port 22, the first one bots try')" \ "$(t 'Un puerto alto (Port 2222) reduce el ruido; ábrelo antes en el cortafuegos' 'A high port (Port 2222) cuts the noise; open it in the firewall first')" else skip "$(t 'No hay servidor SSH' 'No SSH server')" fi # ── Cortafuegos y ataques de fuerza bruta ─────────────────────────────────── section "$(t 'Cortafuegos' 'Firewall')" FW= if [ -n "$ROOT" ]; then if have ufw && ufw status 2>/dev/null | grep -q 'Status: active'; then FW=ufw elif have firewall-cmd && firewall-cmd --state >/dev/null 2>&1; then FW=firewalld elif have nft && nft list ruleset 2>/dev/null | grep -q 'hook input.*policy drop'; then FW=nftables elif have iptables && iptables -S INPUT 2>/dev/null | grep -q '^-P INPUT DROP'; then FW=iptables fi if [ -n "$FW" ]; then ok "$(t 'Cortafuegos activo' 'Firewall active') ($FW)" else bad high "$(t 'Ningún cortafuegos activo en el servidor' 'No firewall active on the server')" \ "$(t 'sudo ufw allow OpenSSH && sudo ufw enable (si filtras en el panel del proveedor, ignóralo)' 'sudo ufw allow OpenSSH && sudo ufw enable (ignore this if your provider filters traffic)')" fi if [ "$FW" = ufw ] && have docker; then pub=$(docker ps --format '{{.Ports}}' 2>/dev/null | tr ',' '\n' | grep -oE '(0\.0\.0\.0|\[?::\]?):[0-9]+' | sed 's/.*://' | grep -vxE '80|443' | sort -un | tr '\n' ' ' | sed 's/ $//') [ -n "$pub" ] && bad medium "$(t "Docker publica puertos saltándose UFW: $pub" "Docker publishes ports bypassing UFW: $pub")" \ "$(t 'Publícalos en 127.0.0.1 (127.0.0.1:5432:5432) o filtra en la cadena DOCKER-USER' 'Publish them on 127.0.0.1 (127.0.0.1:5432:5432) or filter them in the DOCKER-USER chain')" fi else skip "$(t 'Cortafuegos: necesita root' 'Firewall: needs root')" fi running() { if have systemctl; then systemctl is-active --quiet "$1"; else pgrep -x "$1" >/dev/null 2>&1; fi; } if running fail2ban || running crowdsec; then ok "$(t 'Fail2ban o CrowdSec frenan la fuerza bruta' 'Fail2ban or CrowdSec block brute force')" else bad high "$(t 'Nada frena los ataques de fuerza bruta (ni Fail2ban ni CrowdSec)' 'Nothing blocks brute-force attacks (no Fail2ban or CrowdSec)')" \ "$(t 'sudo apt install fail2ban y activa la jaula sshd' 'sudo apt install fail2ban and enable the sshd jail')" fi # ── Puertos abiertos a internet ───────────────────────────────────────────── section "$(t 'Servicios fuera de localhost' 'Services beyond localhost')" if have ss; then LISTEN=$(ss -tlnH 2>/dev/null | awk '{ print $4 }') elif have netstat; then LISTEN=$(netstat -tln 2>/dev/null | awk 'NR > 2 { print $4 }') else LISTEN=; fi if [ -n "$LISTEN" ]; then # Fuera = todas las interfaces o una IP pública. Loopback, redes privadas, # Tailscale (100.64.0.0/10) y ULA/link-local de IPv6 no cuentan. PUBLIC=$(printf '%s\n' "$LISTEN" | grep -vE '^\[?(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|::1\]?:|f[cd]|fe80)' | sed 's/.*://' | sort -un) found= for p in $PUBLIC; do case $p in 6379) n=Redis ;; 27017) n=MongoDB ;; 9200 | 9300) n=Elasticsearch ;; 11211) n=Memcached ;; 3306) n=MySQL ;; 5432) n=PostgreSQL ;; 1433) n='SQL Server' ;; 5984) n=CouchDB ;; 21) n=FTP ;; 23) n=Telnet ;; *) continue ;; esac found=1 case $p in 6379 | 27017 | 9200 | 9300 | 11211) bad critical "$(t "$n escucha fuera de localhost ($p) y no suele pedir contraseña" "$n listens beyond localhost ($p) and often has no password")" \ "$(t "Enlázalo a 127.0.0.1 o ciérralo en el cortafuegos" "Bind it to 127.0.0.1 or block it in the firewall")" ;; *) bad high "$(t "$n escucha fuera de localhost ($p)" "$n listens beyond localhost ($p)")" \ "$(t "Enlázalo a 127.0.0.1 o ábrelo en el cortafuegos solo a las IP que lo necesitan" "Bind it to 127.0.0.1 or allow only the IPs that need it in the firewall")" ;; esac done [ -z "$found" ] && ok "$(t 'Ninguna base de datos ni servicio inseguro expuesto' 'No database or insecure service exposed')" printf ' %s%s %s%s\n' "$DIM" "$(t 'Escuchando fuera de localhost:' 'Listening beyond localhost:')" "$(printf '%s' "$PUBLIC" | tr '\n' ' ')" "$RST" else skip "$(t 'Puertos: no hay ss ni netstat' 'Ports: neither ss nor netstat available')" fi # ── Actualizaciones ───────────────────────────────────────────────────────── section "$(t 'Actualizaciones' 'Updates')" if have apt-get; then UPG=$(apt-get -s -o Debug::NoLocking=1 upgrade 2>/dev/null | grep '^Inst') total=$(printf '%s' "$UPG" | grep -c .) sec=$(printf '%s' "$UPG" | grep -ci security) if [ "$sec" -gt 0 ]; then bad high "$(t "$sec actualizaciones de seguridad pendientes ($total en total)" "$sec security updates pending ($total in total)")" "sudo apt update && sudo apt upgrade" elif [ "$total" -gt 0 ]; then bad medium "$(t "$total actualizaciones pendientes" "$total updates pending")" "sudo apt update && sudo apt upgrade" else ok "$(t 'Sin actualizaciones pendientes (según el último apt update)' 'No pending updates (as of the last apt update)')"; fi if dpkg -s unattended-upgrades 2>/dev/null | grep -q '^Status:.* installed' && apt-config dump 2>/dev/null | grep -q 'APT::Periodic::Unattended-Upgrade "1"'; then ok "$(t 'Los parches de seguridad se instalan solos' 'Security patches install automatically')" else bad medium "$(t 'Los parches de seguridad no se instalan solos' 'Security patches do not install automatically')" \ "sudo apt install unattended-upgrades && sudo dpkg-reconfigure -plow unattended-upgrades" fi elif have dnf; then total=$(dnf -q --cacheonly check-update 2>/dev/null | grep -cE '^[[:alnum:]]') if [ "$total" -gt 0 ]; then bad medium "$(t "$total actualizaciones pendientes" "$total updates pending")" "sudo dnf upgrade" else ok "$(t 'Sin actualizaciones pendientes' 'No pending updates')"; fi else skip "$(t 'Actualizaciones: gestor de paquetes no soportado' 'Updates: unsupported package manager')" fi if [ -f /var/run/reboot-required ]; then bad medium "$(t 'Hay que reiniciar para cargar parches ya instalados' 'A reboot is needed to load installed patches')" "sudo reboot" fi # ── Cuentas y permisos ────────────────────────────────────────────────────── section "$(t 'Cuentas y permisos' 'Accounts and permissions')" uid0=$(awk -F: '$3 == 0 && $1 != "root" { print $1 }' /etc/passwd | tr '\n' ' ') if [ -n "$uid0" ]; then bad critical "$(t "Cuentas con poderes de root además de root: $uid0" "Accounts with root powers besides root: $uid0")" \ "$(t 'Revisa /etc/passwd: nadie más debería tener UID 0' 'Check /etc/passwd: nobody else should have UID 0')" else ok "$(t 'Solo root tiene UID 0' 'Only root has UID 0')"; fi if [ -f /etc/shadow ]; then perm=$(stat -c %a /etc/shadow 2>/dev/null) case "$perm" in *[1-7]) bad high "$(t "/etc/shadow lo puede leer cualquiera ($perm)" "/etc/shadow is world-readable ($perm)")" "sudo chmod 640 /etc/shadow" ;; '') ;; *) ok "$(t '/etc/shadow protegido' '/etc/shadow protected')" ;; esac fi # ── Nota ──────────────────────────────────────────────────────────────────── [ "$SCORE" -lt 0 ] && SCORE=0 if [ "$SCORE" -ge 90 ]; then G=A; elif [ "$SCORE" -ge 75 ]; then G=B; elif [ "$SCORE" -ge 60 ]; then G=C elif [ "$SCORE" -ge 40 ]; then G=D; else G=F; fi case $G in A | B) col=$GRN ;; C) col=$YEL ;; *) col=$RED ;; esac printf '\n%s%s: %s%s/100 (%s)%s %s%s\n' "$BLD" "$(t Nota Score)" "$col" "$SCORE" "$G" "$RST" "$DIM" \ "$(t "críticas: $NC · altas: $NH · medias: $NM · bajas: $NL" "critical: $NC · high: $NH · medium: $NM · low: $NL")$RST" [ "$SKIPPED" -gt 0 ] && printf '%s%s%s\n' "$DIM" "$(t "Comprobaciones saltadas: $SKIPPED. La nota real puede ser peor." "Skipped checks: $SKIPPED. The real score may be lower.")" "$RST" printf '\n%s\n' "$(t 'Esto es una foto de hoy. FerroSentry repite estas comprobaciones cada hora (y más: integridad de ficheros, persistencia, procesos y certificados), te avisa si algo cambia y aplica los arreglos con un clic desde el panel de SecuryBlack. Gratis durante la beta:' \ 'This is a snapshot of today. FerroSentry repeats these checks every hour (and more: file integrity, persistence, processes and certificates), alerts you when something changes and applies the fixes with one click from the SecuryBlack panel. Free during the beta:')" printf ' %shttps://securyblack.com/%s?utm_source=audit_script&utm_medium=cli&utm_campaign=vps_audit%s\n\n' "$BLD" "$(t es en)" "$RST"