En le capítulo anterior vimos como conseguir que tus scripts notifiquen, generan reportes y hablan con APIs. Pero con más poder llega más responsabilidad. Cuando escribes un script Bash, es fácil pensar que solo tú lo ejecutarás, en tu máquina, con tus datos. Pero los scripts crecen, se comparten, se automatizan con cron, se ejecutan como root, reciben entrada de usuarios o APIs.
Un fallo de seguridad en un script puede ser catastrófico:
- Una variable sin comillas puede ejecutar un comando arbitrario
- Un
evalsobre entrada de usuario puede borrar todo el sistema - Un archivo temporal con nombre predecible puede ser secuestrado
- Una variable de entorno filtrada puede exponer contraseñas
¿Por qué es importante la seguridad en scripts?
Cuando escribes un script Bash, es fácil pensar que solo tú lo ejecutarás, en tu máquina, con tus datos. Pero los scripts crecen, se comparten, se automatizan con cron, se ejecutan como root, reciben entrada de usuarios o APIs, y acaban haciendo cosas como borrar archivos, conectarse a bases de datos o manejar claves SSH.
Sanitización de entrada
Toda entrada externa — argumentos, stdin, variables de entorno, archivos, APIs — es potencialmente maliciosa. Nunca confíes en ella.
printf %q — Escapar para shells
printf %q escapa una cadena de forma que el shell la interprete como un único token seguro:
# Escapar un argumento para usarlo en comandos
input="archivo con espacios; rm -rf /"
seguro=$(printf '%q' "$input")
echo "$seguro"
# Output: archivo\ con\ espacios\;\ rm\ -rf\ /
Usos típicos:
# Construir comandos seguros con ssh
comando=$(printf '%q' "$input")
ssh servidor "ls -la $comando"
# Pasar argumentos a otro script
safe=$(printf '%q' "$1")
./otro_script.sh "$safe"
[[ =~ ]] — Validación con expresiones regulares
La validación más segura es rechazar lo que no coincide con un patrón esperado, no tratar de filtrar lo malo:
validar_email() {
local email="$1"
# Patrón estricto de email
if [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
return 0
fi
return 1
}
validar_ip() {
local ip="$1"
if [[ "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
# Validación adicional de rangos
local IFS='.'
read -r o1 o2 o3 o4 <<< "$ip"
(( o1 <= 255 && o2 <= 255 && o3 <= 255 && o4 <= 255 ))
return $?
fi
return 1
}
validar_nombre_archivo() {
local nombre="$1"
# Solo alfanumérico, guiones, puntos y guiones bajos
# Sin barras, sin espacios, sin caracteres de control
if [[ "$nombre" =~ ^[a-zA-Z0-9._-]+$ ]]; then
return 0
fi
return 1
}
Parámetros por expansión — Manipulación segura
Bash ofrece expansiones de parámetros que sanitizan cadenas sin ejecutar nada:
# Eliminar caracteres no deseados (solo dígitos)
num="${input_numero//[^0-9]/}"
# Eliminar caracteres de control y no imprimibles
limpio="${input//[^[:print:]]/}"
# Convertir a minúsculas
minus="${input,,}"
# Truncar longitud máxima
corto="${input:0:50}"
# Valor por defecto si está vacío
nombre="${1:-invitado}"
Combinación de técnicas — validación en capas:
sanitizar_nombre_usuario() {
local input="$1"
local max_len="${2:-32}"
# Eliminar caracteres no ASCII y peligrosos
local limpio="${input//[^a-zA-Z0-9._-]/}"
# Truncar longitud
limpio="${limpio:0:$max_len}"
# Validar que no esté vacío tras sanitizar
if [[ -z "$limpio" ]]; then
echo "ERROR: Nombre de usuario inválido" >&2
return 1
fi
echo "$limpio"
}
Inyección de comandos
El peligro de eval
eval ejecuta su argumento como si fuera código Bash escrito directamente. Esto es extremadamente peligroso con entrada externa:
# ❌ NUNCA hagas esto
nombre="$1"
eval "echo Hola $nombre"
# Input: "; rm -rf /; "
# Ejecuta: echo Hola ; rm -rf / ;
# ¡adiós sistema!
# ❌ Otro ejemplo clásico — eval sobre vars de entorno
eval "comando=\$$1"
# Si $1 es "HOME; rm -rf /", desastre
Regla de oro: si puedes evitar eval, evítalo. Si crees que no puedes, piensa más. Hay una alternativa.
Alternativas seguras a eval
# ❌ Innecesario
eval "echo $VAR"
# ✅ Directo
echo "$VAR"
# ❌ Variables indirectas
eval "valor=\$$nombre_var"
# ✅ Expansión indirecta
valor="${!nombre_var}"
# ❌ Construcción de comandos
eval "$comando $args"
# ✅ Arrays
comando=( "$COMANDO_BASE" "$arg1" "$arg2" )
"${comando[@]}"
# ❌ Matemáticas con eval
eval "resultado=\$(( $expr ))"
# ✅ Aritmética segura
resultado=$(( expr ))
# ❌ Parseo con eval + getopt
args=$(getopt -o "ab:c" -- "$@")
eval set -- "$args"
# ✅ Parseo directo
while getopts ":ab:c" opt; do
case "$opt" in
a) echo "Opción -a" ;; # Procesar flag -a
b) echo "Opción -b: $OPTARG" ;; # Procesar -b con argumento
c) echo "Opción -c" ;; # Procesar flag -c
\?) echo "Opción inválida" >&2 ;;
esac
done
Si ABSOLUTAMENTE necesitas eval (casos muy raros como generación dinámica de código), sanitiza incondicionalmente:
# El único caso aceptable: datos completamente controlados
eval "$(printf '%q' "$comando_seguro")"
Path traversal y manipulación de rutas
basename y realpath
El ataque de path traversal permite acceder a archivos fuera del directorio esperado usando ../:
# ❌ El usuario pasa "../../etc/passwd"
archivo="/var/log/$1"
cat "$archivo" # ¡cat /var/log/../../etc/passwd = /etc/passwd!
# ✅ Usar basename para eliminar componentes de ruta
archivo="/var/log/$(basename "$1")"
cat "$archivo" # Solo "passwd" → /var/log/passwd (no existe)
# ✅ Usar realpath para resolver symlinks y ..
ruta_real=$(realpath "$1" 2>/dev/null) || { echo "Ruta inválida"; exit 1; }
# Verificar que la ruta está dentro de un directorio permitido
if [[ "$ruta_real" != /var/log/allowed/* ]]; then
echo "Acceso denegado: $ruta_real" >&2
exit 1
fi
Validación de rutas segura
es_ruta_segura() {
local ruta="$1"
local permitido="$2"
# Obtener ruta absoluta canónica
local real
real=$(realpath -m "$ruta" 2>/dev/null) || return 1
# Verificar que empieza con el directorio permitido
if [[ "$real" != "$permitido"* ]]; then
return 1
fi
# Verificar que no hay componentes peligrosos residuales
if [[ "$ruta" == *".."* || "$ruta" == *"~"* || "$ruta" == *"//"* ]]; then
return 1
fi
return 0
}
# Uso
if es_ruta_segura "$1" "/var/log/mi_app"; then
cat "$1"
else
echo "Acceso denegado" >&2
exit 1
fi
Permisos de archivos
umask — Permisos por defecto seguros
umask determina los permisos que no se asignan a los archivos nuevos. Un valor seguro elimina permisos de grupo y otros:
# umask actual
umask # (por defecto en muchos sistemas)
# Más restrictivo — solo el propietario escribe
umask 0077
touch secreto.txt
ls -l secreto.txt # -rw-------
# Balanceado — propietario escribe, grupo/otros leen
umask 0022
touch publico.txt
ls -l publico.txt # -rw-r--r--
# Extremo — solo propietario lee y escribe
umask 0177
touch ultra.txt
ls -l ultra.txt # -rw-------
En scripts que manejan datos sensibles, establece umask al inicio:
#!/bin/bash
umask 0077 # Todos los archivos creados: solo el usuario
chmod y chown — Permisos específicos
# Script ejecutable solo por el propietario
chmod 700 script.sh
# Directorio de trabajo accesible solo por el grupo
chmod 750 /opt/mi_app/
chown root:appusers /opt/mi_app/
# Archivo de configuración con secretos
chmod 600 /etc/mi_app/secrets.env
chown root:root /etc/mi_app/secrets.env
# Dar permisos de ejecución sin abrir escritura a otros
chmod 755 bin/herramienta.sh # rwxr-xr-x — ejecutable para todos, solo root escribe
Nunca chmod 777. Es el equivalente a pegar un cartel de «cualquiera puede modificar esto, incluyendo malware».
Verificar permisos antes de operar
verificar_permisos() {
local archivo="$1"
local min_perm="${2:-600}"
if [[ ! -f "$archivo" ]]; then
echo "ERROR: Archivo no existe: $archivo" >&2
return 1
fi
local actual
actual=$(stat -c "%a" "$archivo")
# Los permisos actuales deben ser igual o más restrictivos que min_perm
if (( actual > min_perm )); then
echo "ERROR: Permisos demasiado abiertos en $archivo: $actual (máx: $min_perm)" >&2
return 1
fi
echo "✓ Permisos OK en $archivo: $actual"
return 0
}
Archivos temporales seguros
mktemp con trap cleanup
Crear archivos temporales con nombres predecibles (/tmp/mi_script.tmp) es peligroso: un atacante puede crear un enlace simbólico con ese nombre y redirigir la escritura a otro archivo.
#!/bin/bash
set -euo pipefail
# Crear archivo temporal seguro
TEMP_FILE=$(mktemp /tmp/mi_script.XXXXXX)
TEMP_DIR=$(mktemp -d /tmp/mi_script.XXXXXX)
# Asegurar limpieza siempre — incluso con Ctrl+C
cleanup() {
local exit_code=$?
echo "🧹 Limpiando archivos temporales..." >&2
[[ -f "$TEMP_FILE" ]] && rm -f "$TEMP_FILE"
[[ -d "$TEMP_DIR" ]] && rm -rf "$TEMP_DIR"
exit "$exit_code"
}
trap cleanup EXIT INT TERM
# Trabajar con los archivos temporales
echo "datos sensibles" > "$TEMP_FILE"
procesar_datos < "$TEMP_FILE"
Evitar condiciones de carrera
Una condición de carrera (TOCTOU — Time Of Check, Time Of Use) ocurre cuando verificas un archivo y luego lo usas, pero algo cambió entre medias:
# ❌ TOCTOU: vulnerable
if [[ ! -f /tmp/lock.txt ]]; then
# ❌ Aquí otro proceso puede crear /tmp/lock.txt
echo "$$" > /tmp/lock.txt
fi
# ✅ mktemp es atómico — garantiza que el archivo es nuevo
LOCK_FILE=""
LOCK_FILE=$(mktemp /tmp/mi_app.lock.XXXXXX 2>/dev/null) || {
echo "ERROR: No se pudo crear lock" >&2
exit 1
}
# Usar el archivo como lock (creación atómica)
if ! ln -s "$LOCK_FILE" /tmp/mi_app.lock 2>/dev/null; then
echo "ERROR: Otro proceso ya tiene el lock" >&2
rm -f "$LOCK_FILE"
exit 1
fi
Modo estricto: set -euo pipefail
Es la primera línea de defensa de cualquier script profesional.
set -e (errexit) — Salir ante error
Sin set -e, Bash continúa ejecutando aunque un comando falle:
# Sin set -e
rm -rf /directorio_importante
echo "¡Se borró!" # Se ejecuta aunque rm haya fallado
# Con set -e
set -e
rm -rf /directorio_importante
echo "¡Se borró!" # No se ejecuta si rm falla
Excepciones controladas: a veces un comando puede fallar sin ser error. Usa || true:
# grep devuelve 1 si no encuentra — puede ser aceptable
set -e
if grep -q "patron" archivo.txt; then
echo "Encontrado"
fi
# Si grep no encuentra, set -e mata el script ❌
# ✅ Solución: encadenar con || true
if grep -q "patron" archivo.txt || true; then
...
fi
# O mejor: ignorar el error explícitamente
grep -q "patron" archivo.txt || [[ $? -eq 1 ]]
set -u (nounset) — Variables indefinidas son error
set -u
echo "$NOMBRE" # script.sh: line X: NOMBRE: unbound variable → ¡MUERE!
# ✅ Siempre usa valores por defecto
echo "${NOMBRE:-invitado}"
set -o pipefail — Errores en tuberías
Sin pipefail, solo el último comando de la tubería determina el código de salida:
# Sin pipefail
grep "secreto" /etc/shadow | cut -d: -f1 | wc -l
# grep falla (permiso denegado), pero wc -l devuelve 0 → ¡éxito!
# Con pipefail
set -o pipefail
grep "secreto" /etc/shadow | cut -d: -f1 | wc -l
# Falla con el código de grep (1 o 2)
El trío completo
#!/bin/bash
set -euo pipefail
Esta línea debe estar en todos tus scripts profesionales. Punto.
Comillas y expansión de variables
La regla más simple y más violada: siempre cita las variables.
archivo="mi archivo.txt"
# ❌ Sin comillas — word splitting
rm $archivo # rm "mi" "archivo.txt" → ¡borra dos archivos!
# ✅ Con comillas
rm "$archivo" # rm "mi archivo.txt" → correcto
# ❌ Sin comillas en arrays
args=( -name "*.txt" -type f )
find . ${args[@]} # Word splitting rompe el array
# ✅ Con comillas dobles
find . "${args[@]}" # Cada elemento es un argumento
Expansiones de variables que SIEMPRE deben ir entre comillas:
| Expansión | Sin comillas | Con comillas |
|---|---|---|
$var | Word splitting + globbing | Un solo argumento |
"$@" | Se rompe | Cada arg se preserva |
"${arr[@]}" | Se rompe | Array completo |
$(comando) | Word splitting + globbing | Salida intacta |
$((expr)) | Generalmente seguro | Más seguro |
$var -> archivo | Archivo con espacios → error | Funciona |
Shellshock y otras vulnerabilidades Bash
Shellshock (CVE-2014-6271)
Shellshock es una vulnerabilidad en Bash que permite ejecutar comandos arbitrarios a través de variables de entorno con definiciones de función malformadas:
# Exploit Shellshock
env x='() { :;}; echo VULNERABLE' bash -c "echo prueba"
# Si es vulnerable, imprime "VULNERABLE"
¿Cómo afecta a tus scripts? Si tu script se ejecuta en un contexto donde variables de entorno externas pueden ser inyectadas (CGI, DHCP, SSH), un atacante puede ejecutar código.
Protección:
# Actualizar Bash (parcheado desde 2014)
# Validar variables de entorno externas
if [[ "$ENV_VAR" == *'() {'* ]]; then
echo "Posible ataque Shellshock detectado" >&2
exit 1
fi
# Reinicializar el entorno en scripts críticos
#!/bin/bash
exec env -i HOME="$HOME" PATH="$PATH" bash script_seguro.sh
Otras vulnerabilidades conocidas
| CVE | Año | Descripción | Mitigación |
|---|---|---|---|
| CVE-2014-6271 | 2014 | Shellshock — eval en definición de funciones | Actualizar Bash |
| CVE-2014-7169 | 2014 | Shellshock II — bypass del parche | Actualizar Bash |
| CVE-2019-9924 | 2019 | Command injection en [[ con patrones especiales | Actualizar Bash |
| CVE-2022-3715 | 2022 | Heap overflow en printf | Actualizar Bash |
La mejor protección: mantener Bash actualizado.
SUID/GID en scripts — Por qué es peligroso
El bit SUID (chmod u+s) hace que un ejecutable se ejecute con los permisos de su propietario (normalmente root). En binarios compilados, esto es útil. En scripts, es una puerta abierta a ataques.
# ❌ NUNCA hagas esto en un script
chmod u+s mi_script.sh # ¡PELIGROSÍSIMO!
# ¿Por qué?
Razones por las que SUID en scripts no funciona correctamente:
- El shebang es inseguro: el kernel ejecuta el intérprete (
/bin/bash), no el script. Si un atacante modificaPATHoBASH_ENV, puede ejecutar código como root. - Las variables de entorno no se limpian:
LD_PRELOAD,BASH_ENV,BASH_FUNC_*pueden inyectar código. - Race conditions: entre que se verifica el script y se ejecuta, se puede reemplazar.
# Un atacante podría hacer:
export BASH_ENV=/tmp/malicioso.sh
./script_suid.sh # Ejecuta código malicioso como root
Alternativa segura: sudo con reglas específicas.
Variables de entorno y secretos
read -s para contraseñas
read -s -p "🔑 Contraseña: " password
echo # Salto de línea necesario tras -s
echo "Contraseña recibida (${#password} caracteres)"
# ⚠️ Para scripts críticos, limpia la variable al terminar
procesar_datos "$password"
password="" # Limpiar de memoria (no es perfecto pero ayuda)
Archivos .env (no en git)
# .env — NUNCA hacer commit
DB_HOST=localhost
DB_USER=admin
DB_PASS=s3cr3t0!
API_KEY=sk-1234567890abcdef
# .gitignore
echo ".env" >> .gitignore
# Cargar .env de forma segura
if [[ -f .env ]]; then
set -a # export automático
source .env
set +a
fi
# ⚠️ Mejor aún: cargar solo las variables necesarias
while IFS='=' read -r key value; do
# Ignorar comentarios y líneas vacías
[[ "$key" =~ ^#.*$ || -z "$key" ]] && continue
# Validar que el nombre de variable es seguro
if [[ "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
export "$key=$value"
fi
done < .env
Variables con ámbito restringido
# ❌ Variable global expuesta
API_KEY="sk-123456"
funcion() {
curl -H "Authorization: Bearer $API_KEY" ...
}
# API_KEY visible en /proc/$$/environ, ps, etc.
# ✅ Variable local en función
funcion() {
local api_key="sk-123456"
curl -H "Authorization: Bearer $api_key" ...
}
# api_key solo existe durante la ejecución de la función
# ✅ Exportar solo para un comando específico
DB_PASS="secreto" mi_comando --db-pass "$DB_PASS"
# ⚠️ Limpiar variables de entorno al terminar
export MI_SECRETO="valor"
...
unset MI_SECRETO # Eliminar del entorno
Patrones seguros con sudo
Siempre aplica el principio de mínimo privilegio: da solo los permisos necesarios, solo a quien los necesita, solo por el tiempo necesario.
# ❌ Dar acceso total
# En sudoers: usuario ALL=(ALL) ALL # ¡demasiado!
# ✅ Comandos específicos permitidos
# En sudoers: usuario ALL=(root) /usr/sbin/systemctl restart nginx, /usr/bin/grep
# En el script:
ejecutar_como_root() {
local cmd="$1"
# Validar que el comando está en la lista de permitidos
case "$cmd" in
"reiniciar_nginx")
sudo systemctl restart nginx
;;
"recargar_apache")
sudo systemctl reload apache2
;;
*)
echo "ERROR: Comando no permitido" >&2
return 1
;;
esac
}
Patrón sudo con timeout — evitar sesiones sudo abiertas:
# Forzar sudo a pedir contraseña siempre
sudo -k # Invalida la caché de timestamp
# Ejecutar con timeout
timeout 30 sudo comando_peligroso
# Script con sudo segmented:
sudo_no_interactivo() {
local cmd="$1"
if sudo -n true 2>/dev/null; then
# sudo no necesita contraseña (ya autenticado)
sudo "$@"
else
echo "Se requieren privilegios elevados para: $1" >&2
sudo "$@"
fi
}
Cifrado con GPG y OpenSSL
GPG — Cifrado simétrico
# Cifrar archivo con contraseña
gpg --symmetric --cipher-algo AES256 \
--batch --passphrase "$PASS" \
-o datos_secretos.gpg \
datos_secretos.txt
# Descifrar
gpg --decrypt --batch --passphrase "$PASS" \
-o datos_recuperados.txt \
datos_secretos.gpg
# Cifrado asimétrico (clave pública)
gpg --encrypt --recipient "admin@ejemplo.com" \
-o secreto.gpg \
secreto.txt
OpenSSL — Alternativa ligera
# Cifrado AES-256-CBC con derivación PBKDF2
openssl enc -aes-256-cbc -salt -pbkdf2 \
-pass pass:"$PASS" \
-in datos.txt \
-out datos.enc
# Descifrar
openssl enc -d -aes-256-cbc -salt -pbkdf2 \
-pass pass:"$PASS" \
-in datos.enc \
-out datos_recuperados.txt
# Cifrado con clave generada desde archivo
openssl rand -base64 32 > clave_aes.key
openssl enc -aes-256-cbc -salt -pbkdf2 \
-pass file:clave_aes.key \
-in datos.txt \
-out datos.enc
Nunca pases la contraseña por argumento si puedes evitarlo — otros usuarios la ven en ps:
# ❌ MAL — visible en ps
./script.sh --password "secreto"
# ✅ MEJOR — variable de entorno
export PASS="secreto"
./script.sh
# ✅ MEJOR aún — archivo con permisos 600
./script.sh --password-file /etc/secrets/pass.txt
# ⚠️ O usa openssl con file descriptor
openssl enc -aes-256-cbc -salt -pbkdf2 \
-pass fd:3 \
3< <(echo -n "$PASS") \
-in datos.txt -out datos.enc
Gestión de claves SSH
# Generar clave SSH con frase de protección
ssh-keygen -t ed25519 -a 100 -f ~/.ssh/id_ed25519 -N "$FRASE"
# Usar agente SSH para no exponer la frase
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519
# Script que usa SSH de forma segura
ejecucion_ssh_segura() {
local host="$1"
local comando="$2"
# Validar host contra lista blanca
case "$host" in
server1.ejemplo.com|server2.ejemplo.com)
;;
*)
echo "ERROR: Host no autorizado: $host" >&2
return 1
;;
esac
# Sanitizar el comando
local cmd_seguro
cmd_seguro=$(printf '%q' "$comando")
# Ejecutar con timeout y opciones estrictas
ssh -o StrictHostKeyChecking=yes \
-o ConnectTimeout=10 \
-o ServerAliveInterval=30 \
-i ~/.ssh/deploy_key \
"admin@$host" "$cmd_seguro"
}
Auditoría y logging (syslog)
Todo script que ejecute acciones sensibles debe registrar quién, qué y cuándo.
# Logging a syslog
logger -t mi_script -p user.info "Inicio de ejecución por $USER"
logger -t mi_script -p user.warning "Archivo no encontrado: $archivo"
logger -t mi_script -p user.err "Operación fallida: código $?"
# Enviar a archivo con formato estructurado
auditar() {
local accion="$1"
local resultado="$2"
local detalles="${3:-}"
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
# Formato: timestamp | usuario | acción | resultado | detalles
echo "$timestamp | $USER | $accion | $resultado | $detalles" >> /var/log/mi_app/auditoria.log
# También a syslog para SIEM centralizado
logger -t mi_app -p user.notice "$USER | $accion | $resultado | $detalles"
}
# Uso
auditar "Eliminar usuario" "EXITO" "usuario=jdoe"
# Script completo de auditoría
#!/bin/bash
AUDIT_LOG="/var/log/mi_app/auditoria.log"
AUDIT_ENABLED=true
auditar() {
$AUDIT_ENABLED || return 0
local accion="$1"
local resultado="$2"
shift 2
local detalles="$*"
local ts
ts=$(date --iso-8601=seconds)
# PID, TTY, usuario real (SUDO_USER si existe)
local usuario_real="${SUDO_USER:-$USER}"
local tty=$(tty 2>/dev/null || echo "no-tty")
printf "[%s] PID=$$ USER=%s TTY=%s ACCION=%s RESULTADO=%s DETALLES=%s\n" \
"$ts" "$usuario_real" "$tty" "$accion" "$resultado" "$detalles" \
| tee -a "$AUDIT_LOG" | logger -t mi_app -p user.notice
}
rm seguro — Protección contra borrados accidentales
# ❌ Clásico error
cd /directorio_importante
rm -rf * # Si pwd falló, estás en / y borras TODO
# ✅ Verificar directorio antes
if [[ "$PWD" != "/directorio_importante" ]]; then
echo "ERROR: No estás en el directorio correcto" >&2
exit 1
fi
rm -rf ./*
# ✅ Usar trash-put (si está disponible)
if command -v trash-put &>/dev/null; then
trash-put archivo_borrar
else
rm -i archivo_borrar # -i pide confirmación
fi
# ✅ rm seguro con confirmación y log
rm_seguro() {
local archivo="$1"
# Validar que el archivo existe
if [[ ! -f "$archivo" && ! -d "$archivo" ]]; then
echo "ERROR: No existe: $archivo" >&2
return 1
fi
# Confirmar
read -p "¿Borrar '$archivo' irreversiblemente? (s/N): " confirm
if [[ "$confirm" != [sS] ]]; then
echo "Operación cancelada"
return 0
fi
# Registrar en log
logger -t rm_seguro "Usuario $USER borró: $(realpath "$archivo")"
# Borrar
rm -rf "$archivo"
}
# ✅ Usar rm con --one-file-system (no cruza montajes)
rm -rf --one-file-system /tmp/datos_viejos/
# ✅ Mover a un directorio trash antes de borrar
TRASH_DIR="/tmp/.trash_$USER"
mkdir -p "$TRASH_DIR"
mv archivo_borrar "$TRASH_DIR/$(date +%s)_$(basename "$archivo")"
# Luego un cron se encarga de vaciar el trash periódicamente
Funciones profesionales
Función 1: sanitizar_input — Filtro de entrada universal
# Sanitiza una cadena eliminando caracteres peligrosos
# Uso: sanitizar_input <cadena> [tipo] [max_len]
# tipo: alfanumerico | nombre | ruta | email | ip | comando
# max_len: longitud máxima (defecto 256)
sanitizar_input() {
local input="$1"
local tipo="${2:-alfanumerico}"
local max_len="${3:-256}"
local limpio
# Truncar
limpio="${input:0:$max_len}"
case "$tipo" in
alfanumerico)
limpio="${limpio//[^a-zA-Z0-9]/}"
;;
nombre)
# Letras, números, guiones, espacios, tildes
limpio="${limpio//[^a-zA-Z0-9áéíóúÁÉÍÓÚüÜñÑ _-]/}"
;;
ruta)
# Solo caracteres de ruta Unix seguros
limpio="${limpio//[^a-zA-Z0-9._-\/]/}"
# Eliminar barras dobles
while [[ "$limpio" == *"//"* ]]; do
limpio="${limpio//\/\///}"
done
# Eliminar .. peligrosos
limpio="${limpio//../}"
;;
email)
limpio="${limpio//[^a-zA-Z0-9.@_%+-]/}"
;;
ip)
limpio="${limpio//[^0-9.]/}"
;;
comando)
# Para construir comandos, escapar con printf %q
printf '%q' "$limpio"
return
;;
*)
echo "ERROR: Tipo de sanitización desconocido: $tipo" >&2
return 1
;;
esac
echo "$limpio"
}
Función 2: tempfile_seguro — Archivo temporal con cleanup
# Crea un archivo temporal seguro con limpieza automática
# Uso: tempfile_seguro [prefijo] [directorio]
# Define: TEMP_FILE, TEMP_DIR
# Registra trap EXIT para limpieza
tempfile_seguro() {
local prefijo="${1:-script}"
local dir="${2:-/tmp}"
local temp_file
local temp_dir
# Crear archivo temporal
temp_file=$(mktemp "${dir}/${prefijo}.XXXXXX") || {
echo "ERROR: No se pudo crear archivo temporal" >&2
return 1
}
# Crear directorio temporal
temp_dir=$(mktemp -d "${dir}/${prefijo}_dir.XXXXXX") || {
rm -f "$temp_file"
echo "ERROR: No se pudo crear directorio temporal" >&2
return 1
}
# Exportar para que el trap los conozca
TEMP_FILE="$temp_file"
TEMP_DIR="$temp_dir"
export TEMP_FILE TEMP_DIR
# Registrar cleanup (solo una vez)
if [[ -z "${_TEMP_CLEANUP_REGISTERED:-}" ]]; then
_temp_cleanup() {
[[ -f "$TEMP_FILE" ]] && rm -f "$TEMP_FILE"
[[ -d "$TEMP_DIR" ]] && rm -rf "$TEMP_DIR"
}
trap _temp_cleanup EXIT INT TERM
export _TEMP_CLEANUP_REGISTERED=1
fi
echo "$temp_file"
}
Función 3: cifrar_backup — Cifrado seguro de archivos
# Cifra un archivo con AES-256 (OpenSSL o GPG)
# Uso: cifrar_backup <archivo_origen> [archivo_destino]
cifrar_backup() {
local origen="$1"
local destino="${2:-${origen}.enc}"
local pass="${PASSWORD_CIFRADO:-}"
local pass_file="${PASSFILE:-}"
# Validar archivo origen
if [[ ! -f "$origen" ]]; then
echo "ERROR: Archivo origen no encontrado: $origen" >&2
return 1
fi
# Obtener contraseña
if [[ -z "$pass" && -n "$pass_file" && -f "$pass_file" ]]; then
pass=$(< "$pass_file")
fi
if [[ -z "$pass" ]]; then
read -s -p "🔑 Contraseña de cifrado: " pass
echo >&2
fi
# Cifrar con OpenSSL
if command -v openssl &>/dev/null; then
openssl enc -aes-256-cbc -salt -pbkdf2 \
-pass pass:"$pass" \
-in "$origen" \
-out "$destino" 2>/dev/null && {
echo "✓ Archivo cifrado: $origen → $destino"
# Limpiar variable de memoria (no perfecto pero ayuda)
pass=""
return 0
}
fi
# Fallback a GPG
if command -v gpg &>/dev/null; then
gpg --symmetric --cipher-algo AES256 \
--batch --passphrase "$pass" \
-o "$destino" \
"$origen" 2>/dev/null && {
echo "✓ Archivo cifrado con GPG: $origen → $destino"
pass=""
return 0
}
fi
echo "ERROR: No se pudo cifrar el archivo" >&2
pass=""
return 1
}
Función 4: auditar_evento — Registro de auditoría estructurado
# Registra un evento de auditoría en syslog y archivo
# Uso: auditar_evento <accion> <resultado> [detalles...]
# Variables de entorno: AUDIT_LOG (archivo de log opcional)
auditar_evento() {
local accion="$1"
local resultado="$2"
shift 2
local detalles="$*"
local timestamp
local usuario_real
local tty_actual
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
usuario_real="${SUDO_USER:-$USER}"
tty_actual=$(tty 2>/dev/null || echo "?")
local pid="$$"
# Formato estructurado: TS | PID | USUARIO | TTY | ACCION | RESULTADO | DETALLES
local mensaje="[$timestamp] PID=$pid USER=$usuario_real TTY=$tty_actual ACCION=$accion RESULTADO=$resultado DETALLES=$detalles"
# Syslog
logger -t script_audit -p user.notice "$mensaje"
# Archivo (si se configuró)
if [[ -n "${AUDIT_LOG:-}" ]]; then
echo "$mensaje" >> "$AUDIT_LOG"
fi
# Stderr si es advertencia o error
if [[ "$resultado" == "ERROR" || "$resultado" == "FALLO" ]]; then
echo "⚠ $mensaje" >&2
fi
}
Errores comunes
Error 1: No citar variables
# ❌ Explotable con inyección
archivo="$1"
cat $archivo
# Input: "archivo.txt ; echo HACKED"
# cat ejecuta "cat archivo.txt ; echo HACKED" → ¡dos comandos!
# ✅ Siempre citar
cat "$archivo"
# cat "archivo.txt ; echo HACKED" → un solo argumento (archivo no existe)
Error 2: Usar eval innecesariamente
# ❌ eval sobre datos externos
eval "echo $1"
# ./script.sh "$(rm -rf /)" → ¡desastre!
# ✅ printf %q o expansión directa
printf '%q\n' "$1"
# ✅ O simplemente
echo "$1"
Error 3: Ignorar el modo estricto
# ❌ Script sin protección
#!/bin/bash
# Si falla un comando, el script sigue como si nada
# ✅ Siempre poner al inicio
#!/bin/bash
set -euo pipefail
Error 4: Archivos temporales con nombre predecible
# ❌ Nombre fijo — ataque de symlink
echo "datos" > /tmp/mi_script.tmp
# Un atacante crea: ln -s /etc/passwd /tmp/mi_script.tmp
# ¡Sobreescribes /etc/passwd!
# ✅ mktemp con sufijo aleatorio
TEMP=$(mktemp /tmp/mi_script.XXXXXX)
Error 5: Scripts SUID
# ❌ Jamás
chmod u+s script.sh
# ✅ sudo con reglas específicas en /etc/sudoers
# usuario ALL=(root) /usr/local/bin/script.sh
Error 6: No limpiar trap en archivos temporales
# ❌ Si el script falla o recibe Ctrl+C, /tmp queda lleno
TEMP=$(mktemp)
procesar "$TEMP"
# Nunca se borra si falla
# ✅ Cleanup con trap
cleanup() { rm -f "$TEMP"; }
trap cleanup EXIT INT TERM
TEMP=$(mktemp)
procesar "$TEMP"
Error 7: Exponer contraseñas por argumento
# ❌ Visible en ps aux
./script.sh --password "supersecreto"
# ✅ Variable de entorno (mejor) o archivo con permisos 600
export DB_PASS="supersecreto"
./script.sh
Error 8: No validar entrada con regex
# ❌ Validación débil
if [[ "$1" == "admin" ]]; then
echo "Acceso concedido"
fi
# ✅ Validación con regex y listas blancas
patron_admin='^admin[0-9]+$'
if [[ "$1" =~ $patron_admin ]]; then
echo "Acceso concedido a $1"
fi
Error 9: No usar -- para delimitar opciones
# ❌ Si el archivo empieza con -, se interpreta como opción
rm $archivo
# archivo="-rf" → ¡rm interpreta -rf como opción!
# ✅ Usar -- para indicar fin de opciones
rm -- "$archivo"
# ✅ También aplica a otros comandos
cat -- "$archivo"
grep -- "$patron" "$archivo"
Error 10: Confiar en la integridad del PATH
# ❌ PATH manipulable — inyección de comandos
# Si PATH contiene /tmp, el atacante pone un falso "ls"
ls
# ✅ Usar rutas absolutas en scripts críticos
/bin/ls
/usr/bin/grep
# ✅ O establecer PATH seguro al inicio del script
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
Resumen
| Concepto | Herramienta / Sintaxis | Propósito |
|---|---|---|
| Sanitización | printf '%q' "$var" | Escapar para shells |
| Validación regex | [[ "$var" =~ ^patrón$ ]] | Lista blanca de entrada |
| Eliminar caracteres | ${var//[^a-z]/} | Filtrar caracteres no deseados |
| Modo estricto | set -euo pipefail | Fallar ante cualquier error |
| Citar variables | "$var" | Evitar word splitting y globbing |
| Archivos temporales | mktemp ...XXXXXX | Crear archivos con nombres impredecibles |
| Cleanup | trap cleanup EXIT INT TERM | Limpiar recursos siempre |
| umask seguro | umask 0077 | Archivos privados por defecto |
| No eval | "${comando[@]}" o printf %q | Evitar ejecución de código arbitrario |
| Path traversal | basename, realpath | Validar y resolver rutas |
| Sin SUID | sudo con reglas específicas | Ejecución privilegiada segura |
| Secretos | read -s, .env, vars locales | Proteger contraseñas en scripts |
| Cifrado | openssl enc -aes-256-cbc -pbkdf2 | Cifrar datos sensibles |
| Auditoría | logger -t script -p user.notice | Registrar eventos de seguridad |
| rm seguro | --one-file-system, trash-put | Prevenir borrados accidentales |
| Separar opciones | -- | Indicar fin de opciones en comandos |
| PATH seguro | export PATH="/usr/bin:/bin" | Prevenir inyección por PATH |
| Arrays | "${arr[@]}" | Construcción segura de comandos |
| Variables indirectas | "${!nombre_var}" | Alternativa a eval para vars dinámicas |
| GPG simétrico | gpg --symmetric --cipher-algo AES256 | Cifrado con contraseña |
Más información,
- OWASP Command Injection — Guía de prevención de inyección de comandos
- Bash Security Vulnerabilities — Listado histórico de CVE de Bash
- Shellshock (CVE-2014-6271) — Documentación y test de la vulnerabilidad Shellshock
- Bash Hackers Wiki — Security — Prácticas obsoletas y peligrosas
- Google Shell Style Guide — Guía de estilo de Google para Bash (incluye seguridad)
- MITRE ATT&CK — Command and Scripting Interpreter — Técnicas de ataque con scripts
- Linux Security: su vs sudo — Documentación oficial de sudo
- mktemp security considerations — Consideraciones de seguridad de archivos temporales
- CIS Benchmarks — Guías de hardening para Linux
- ShellCheck — Herramienta de análisis estático para scripts Bash (detecta muchos problemas de seguridad)