19

Expresiones Regulares en Bash

Vistas: 1
Tutorial: Scripts en Bash
Expresiones Regulares en Bash

Como te comenté en el capítulo anterior, trabajar con texto en Bash es pan de cada día. Pero, ¿qué pasa cuando tienes que buscar algo más concreto que una palabra exacta? ¿Cuando necesitas encontrar todas las líneas que empiezan por «ERROR», contienen un número de tres o cuatro dígitos, y terminan con un punto? Ahí es donde entran las expresiones regulares.

Soy consciente que esto de las expresiones regulares, en general son un dolor, pero cuando descubres el potencial de las mismas, cuando descubres que están por todas partes, y cuando te das cuenta que tampoco es tan complicado, tienes un arma realmente potente al alcance de tus manos.

¿Que es eso de las expresiones regulares?

Las expresiones regulares son uno de esos recursos que, una vez que los dominas, cambian por completo tu forma de trabajar con texto. En Bash las tienes en todas partes: grep, sed, awk, y el propio shell con el operador [[ =~ ]].

Pero, ¿qué es exactamente una expresión regular? Pues una secuencia de caracteres que define un patrón de búsqueda. Piensa en ella como un mini-lenguaje para describir texto.

Con las expresiones regulares puedes:

  • Buscar patrones en archivos y cadenas
  • Extraer partes específicas de un texto
  • Validar formatos (emails, IPs, fechas, URLs)
  • Reemplazar texto siguiendo patrones
  • Filtrar líneas que cumplan (o no) una condición

BRE y ERE: los dos sabores estándar

Bash y las herramientas clásicas de Unix soportan dos tipos de expresiones regulares. Vamos a verlos.

BRE (Basic Regular Expressions): Es el modo por defecto en grep y sed. Los metacaracteres ?, +, {, |, ( y ) pierden su significado especial a menos que los escapes con \\.

# BRE: ? y + son literales, hay que escaparlos
grep 'colo\\?r' archivo.txt      # colr o color
grep 'colo\\+r' archivo.txt      # color, coloor, colooor...

# BRE: hay que escapar ( ) y { }
grep '\\(error\\|warning\\)' log.txt
grep 'a\\{3\\}' archivo.txt       # exactamente 3 aes

ERE (Extended Regular Expressions): Es el modo de egrep o grep -E. Aquí los metacaracteres ?, +, {, |, (, ) funcionan sin escapar:

# ERE: sin escapar
grep -E 'colo?r' archivo.txt
grep -E 'colo+r' archivo.txt
grep -E '(error|warning)' log.txt
grep -E 'a{3}' archivo.txt
grep -E '^[0-9]{1,3}\\.[0-9]{1,3}\\.' ip.txt

PCRE (Perl Compatible Regular Expressions): grep -P (si está compilado con soporte) ofrece características modernas como lookahead, lookbehind y \\d:

grep -P '^ERROR.*(?=\\d{4})' log.txt
grep -P '\\d{3}-\\d{2}-\\d{4}' datos.txt  # SSN

grep: buscar con patrones

grep es la herramienta reina de la búsqueda de texto. Te recomiendo que te familiarices bien con sus opciones porque las vas a usar a diario.

Opciones esenciales con regex:

  • -E: Modo ERE extendido. grep -E '[0-9]{3}-[0-9]{4}'
  • -P: Modo PCRE (Perl). grep -P '(?<=ERROR: ).*'
  • -o: Solo muestra el texto que coincide. grep -oE '[0-9]+\\.[0-9]+'
  • -c: Cuenta coincidencias. grep -cE 'ERROR|FATAL' log.txt
  • -v: Invierte: líneas que NO coinciden. grep -vE '^#' config.sh
  • -i: Ignora mayúsculas/minúsculas. grep -i 'error' log.txt
  • -n: Muestra números de línea. grep -nE 'ERROR.*timeout' log.txt
  • -l: Solo nombres de archivo. grep -lE 'TODO|FIXME' src/*.py
  • -r: Búsqueda recursiva. grep -rE 'main\\(.*\\)' ./src

Ejemplo 1: Buscar números de teléfono

# Patrones: +34 612 345 678, 612345678, 612 34 56 78
grep -oE '\\\\+?34?[ -]?[0-9]{3}[ -]?[0-9]{3}[ -]?[0-9]{3}' contactos.txt

✍️ Explicación del patrón: \+?34?[ -]?[0-9]{3}[ -]?[0-9]{3}[ -]?[0-9]{3}

  • \+? — Prefijo internacional + opcional
  • 34? — Código de país: 3 seguido de 4 opcional (cubre +34 y números sin prefijo)
  • [ -]? — Separador opcional: espacio o guión
  • [0-9]{3} — Exactamente 3 dígitos, repetido tres veces para los 9 dígitos
  • El patrón acepta +34 612 345 678, 612345678 o 612 34 56 78

Ejemplo 2: Extraer direcciones de correo

grep -oE '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}' emails.txt

✍️ Explicación del patrón: [a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.\n[a-zA-Z]{2,}

  • [a-zA-Z0-9._%+-]+ — Parte local: uno o más caracteres alfanuméricos o especiales
  • @ — Arroba literal
  • [a-zA-Z0-9.-]+ — Nombre de dominio (letras, dígitos, puntos y guiones)
  • \\. — Punto literal (escapado)
  • [a-zA-Z]{2,} — TLD: al menos 2 letras (.es, .com, .org…)

Ejemplo 3: Contar errores por categoría

echo "ERRORs: $(grep -cE 'ERROR' log.txt)"
echo "WARNINGs: $(grep -cE 'WARNING' log.txt)"
echo "INFOs: $(grep -cE 'INFO' log.txt)"

sed: editar con patrones

sed (stream editor) te permite buscar y reemplazar texto usando expresiones regulares. Es una herramienta que, una vez que le coges el truco, te ahorrará montones de tiempo.

s/// — el comando de sustitución:

# Sintaxis: sed 's/patrón/reemplazo/flags'

# Reemplazar la primera ocurrencia por línea
sed 's/ERROR/AVISO/' log.txt

# Reemplazar todas las ocurrencias (flag g)
sed 's/ERROR/AVISO/g' log.txt

# Solo en líneas que contengan "2024"
sed '/2024/s/ERROR/AVISO/g' log.txt

# Usar ERE con -E
sed -E 's/[0-9]{4}-[0-9]{2}-[0-9]{2}/FECHA/g' log.txt

Captura con backreferences:

# Invertir nombre: "Pérez, Juan" → "Juan Pérez"
sed -E 's/([^,]+), (.+)/\\2 \\1/' nombres.txt

> **✍️ Explicación del patrón:** `([^,]+), (.+)`
>
> - `([^,]+)` — Grupo 1: uno o más caracteres que NO sean coma (el apellido)
> - `, ` — Coma y espacio literales
> - `(.+)` — Grupo 2: uno o más caracteres (el nombre)
> - El reemplazo `\\2 \\1` invierte los grupos: nombre + apellido
>
> **Captura con backreferences:**

# Cambiar formato de fecha: 2024-01-15 → 15/01/2024
sed -E 's/([0-9]{4})-([0-9]{2})-([0-9]{2})/\\3\\/\\2\\/\\1/' fechas.txt

> **✍️ Explicación del patrón:** `([0-9]{4})-([0-9]{2})-([0-9]{2})`
>
> - `([0-9]{4})` — Grupo 1: año, exactamente 4 dígitos
> - `-` — Guión literal (separador)
> - `([0-9]{2})` — Grupo 2: mes, exactamente 2 dígitos
> - `-` — Guión literal
> - `([0-9]{2})` — Grupo 3: día, exactamente 2 dígitos
> - Reemplazo `\\3/\\2/\\1`: reordena a día/mes/año
>
> ### Enmascarar email

# Enmascarar email: lorenzo@atareao.es → l***@atareao.es
sed -E 's/(.)[^@]+@/\\1***@/' emails.txt

> **✍️ Explicación del patrón:** `(.)[^@]+@`
>
> - `(.)` — Grupo 1: el primer carácter del nombre de usuario
> - `[^@]+` — Uno o más caracteres que NO sean `@` (el resto del nombre)
> - `@` — Arroba literal
> - Reemplazo `\\1***@`: conserva la inicial, oculta el resto con `***`
>
> **Imprimir solo líneas modificadas con `-n` y `p`:**

Imprimir solo líneas modificadas con -n y p:

# Mostrar solo líneas que coinciden con el patrón
sed -n '/ERROR/p' log.txt

# Mostrar líneas transformadas
sed -n 's/ERROR/AVISO/gp' log.txt

# Rango de líneas con regex
sed -n '/^BEGIN/,/^END/p' documento.txt

awk: el todoterreno del texto

awk es mucho más que un buscador de patrones. Es un lenguaje de procesamiento de texto completo, y las expresiones regulares son parte fundamental de su sintaxis. Dale caña, que merece la pena.

El operador ~ (match):

# Líneas donde el campo 1 coincide con el patrón
awk '$1 ~ /^ERROR/' log.txt

# Líneas donde el campo 3 NO coincide
awk '$3 !~ /^INFO/' log.txt

# Combinar condiciones
awk '$1 ~ /^ERROR/ && $3 > 100' log.txt

La función match():

# Extraer la parte que coincide
awk 'match($0, /[0-9]+\\\\.[0-9]+\\\\.[0-9]+\\\\.[0-9]+/) {
    print substr($0, RSTART, RLENGTH)
}' log.txt

> **✍️ Explicación del patrón:** `[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+`
>
> - `[0-9]+` — Uno o más dígitos (cada octeto de la IP)
> - `\.` — Punto literal (escapado para no confundirlo con "cualquier carácter")
> - El patrón se repite 4 veces para los 4 octetos
> - ⚠️ Este patrón acepta octetos >255 como `999`. Para validación real, usa `extraer_ips_validas`

# match() con captura en versiones modernas de awk
awk 'match($0, /error: (.+) on line ([0-9]+)/, arr) {
    print "Error:", arr[1], "en línea", arr[2]
}' log.txt

> **✍️ Explicación del patrón:** `error: (.+) on line ([0-9]+)`
>
> - `error: ` — Texto literal que introduce el mensaje
> - `(.+)` — Grupo 1: el mensaje de error (uno o más caracteres)
> - ` on line ` — Texto literal entre mensaje y número
> - `([0-9]+)` — Grupo 2: el número de línea (uno o más dígitos)
>
> **Split con regex:**

Split con regex:

# Dividir campos por cualquier combinación de espacios y puntuación
awk '{split($0, arr, /[[:space:],;:]+/); print arr[1], arr[3]}' data.txt

✍️ Explicación del patrón: [[:space:],;:]+

  • [[:space:]] — Cualquier espacio en blanco
  • ,;: — Coma, punto y coma, dos puntos literales
  • + — Uno o más de estos caracteres consecutivos
  • El resultado: divide el texto por cualquier combinación de espacios y signos de puntuación

Ejemplo práctico: analizar log de acceso web

# Extraer IPs que hicieron más de 100 peticiones
awk 'match($0, /^([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)/, ip) {
    ips[ip[1]]++
}
END {
    for (ip in ips) {
        if (ips[ip] > 100) print ip, ips[ip]
    }
}' access.log | sort -k2 -rn

> **✍️ Explicación del patrón:** `^([0-9]+\\.\n[0-9]+\\.\n[0-9]+\\.\n[0-9]+)`
>
> - `^` — Inicio de línea (la IP está al principio del log)
> - `([0-9]+\\.\n[0-9]+\\.\n[0-9]+\\.\n[0-9]+)` — Grupo 1: captura los 4 octetos de la IP
> - `awk` acumula peticiones por IP en un array y al final filtra las que superan 100
>
> **`[[ =~ ]]` — regex nativo en Bash**

[[ =~ ]] — regex nativo en Bash

Bash incorpora expresiones regulares directamente en el shell mediante el operador =~ dentro de [[ ... ]]. Esto te puede ahorrar más de un disgusto cuando necesitas validar datos sin depender de herramientas externas.

#!/bin/bash

email="usuario@atareao.es"

if [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$ ]]; then
    echo "Email válido"
else
    echo "Email inválido"
fi

✍️ Explicación del patrón: ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$

  • ^ — Inicio de cadena
  • [a-zA-Z0-9._%+-]+ — Parte local del email
  • @ — Arroba literal
  • [a-zA-Z0-9.-]+ — Nombre de dominio
  • \. — Punto literal antes del TLD
  • [a-zA-Z]{2,} — TLD de al menos 2 letras
  • $ — Final de cadena

BASH_REMATCH: acceder a los grupos de captura

Cuando usas paréntesis de captura en [[ =~ ]], los resultados se guardan en el array BASH_REMATCH. Cuidado con esto: BASH_REMATCH es 1-indexado. BASH_REMATCH[0] contiene la coincidencia completa, BASH_REMATCH[1] el primer grupo, etc. Este es uno de los errores más comunes.

#!/bin/bash

log_line="2024-01-15 10:30:45 ERROR: Conexión fallida (timeout=30s)"

patron='^([0-9]{4}-[0-9]{2}-[0-9]{2}) ([0-9]{2}:[0-9]{2}:[0-9]{2}) (ERROR|WARNING|INFO): (.+)$'

if [[ "$log_line" =~ $patron ]]; then
    echo "Fecha:     ${BASH_REMATCH[1]}"
    echo "Hora:      ${BASH_REMATCH[2]}"
    echo "Nivel:     ${BASH_REMATCH[3]}"
    echo "Mensaje:   ${BASH_REMATCH[4]}"
fi

✍️ Explicación del patrón:

  • ^([0-9]{4}-[0-9]{2}-[0-9]{2}) — Grupo 1: fecha YYYY-MM-DD
  • ([0-9]{2}:[0-9]{2}:[0-9]{2}) — Grupo 2: hora HH:MM:SS
  • (ERROR|WARNING|INFO): — Grupo 3: nivel del log
  • (.+)$ — Grupo 4: mensaje hasta el final
  • Se accede con BASH_REMATCH[1] a BASH_REMATCH[4]

No uses comillas en el patrón:

# Mal: las comillas convierten el patrón en una cadena literal
if [[ "$texto" =~ "^[0-9]+$" ]]; then ...

# Bien: sin comillas, es una expresión regular
if [[ "$texto" =~ ^[0-9]+$ ]]; then ...

Si necesitas guardar el patrón en una variable (te lo recomiendo para evitar problemas de escaping), hazlo así:

patron='^[0-9]+$'
if [[ "$texto" =~ $patron ]]; then
    echo "Es numérico"
fi

Ejemplo completo: parsear línea de log

#!/bin/bash
# parsear_log.sh — Extrae información de logs con regex nativo

parsear_log() {
    local linea="$1"
    local patron

    # Patrones para diferentes formatos de log
    patron='^([0-9]{4}/[0-9]{2}/[0-9]{2}) ([0-9]{2}:[0-9]{2}:[0-9]{2}) \[([A-Z]+)\] (.+)$'

    if [[ "$linea" =~ $patron ]]; then

> **✍️ Explicación del patrón:** `^([0-9]{4}/[0-9]{2}/[0-9]{2}) ([0-9]{2}:[0-9]{2}:[0-9]{2}) \[([A-Z]+)\] (.+)$`
>
> - `^([0-9]{4}/[0-9]{2}/[0-9]{2})` — Grupo 1: fecha con `/`
> - ` ([0-9]{2}:[0-9]{2}:[0-9]{2})` — Grupo 2: hora
> - ` \[([A-Z]+)\]` — Grupo 3: nivel entre corchetes
> - ` (.+)$` — Grupo 4: mensaje
>
        echo "Fecha: ${BASH_REMATCH[1]}"
        echo "Hora: ${BASH_REMATCH[2]}"
        echo "Nivel: ${BASH_REMATCH[3]}"
        echo "Mensaje: ${BASH_REMATCH[4]}"
        return 0
    fi

    # Intentar otro formato
    patron='^([A-Z]+): (.+)$'
    if [[ "$linea" =~ $patron ]]; then

> **✍️ Explicación del patrón:** `^([A-Z]+): (.+)$`
>
> - `^([A-Z]+)` — Grupo 1: una o más mayúsculas (el nivel)
> - `: ` — Separador literal
> - `(.+)$` — Grupo 2: mensaje hasta el final
>
        echo "Nivel: ${BASH_REMATCH[1]}"
        echo "Mensaje: ${BASH_REMATCH[2]}"
        return 0
    fi

    return 1
}

Caracteres especiales y clases de caracteres

Metacaracteres básicos:

  • .: Cualquier carácter excepto nueva línea. c.sa → «casa», «cesa», «c0sa»
  • ^: Inicio de línea. ^ERROR → líneas que empiezan con ERROR
  • $: Final de línea. .$ → líneas que terminan con punto
  • \\: Escape. \\. → punto literal

Clases predefinidas POSIX:

  • [[:alnum:]]: Alfanuméricos. Equivale a [a-zA-Z0-9]
  • [[:alpha:]]: Letras. Equivale a [a-zA-Z]
  • [[:digit:]]: Dígitos. Equivale a [0-9]
  • [[:lower:]]: Minúsculas. Equivale a [a-z]
  • [[:upper:]]: Mayúsculas. Equivale a [A-Z]
  • [[:space:]]: Espacios en blanco. Equivale a [ \t\n\r\f\v]
  • [[:punct:]]: Puntuación. [.,!?:;...]
  • [[:print:]]: Caracteres imprimibles
  • [[:graph:]]: Caracteres visibles (no espacio)
  • [[:xdigit:]]: Dígitos hexadecimales. [0-9a-fA-F]
  • [[:blank:]]: Espacio y tabulador. [ \t]
  • [[:cntrl:]]: Caracteres de control

Las clases POSIX son universales, portables y respetan la localización del sistema. Son preferibles a los rangos [a-z] que pueden no incluir caracteres acentuados.

# Extraer solo palabras (alfanuméricas)
grep -oE '[[:alpha:]]+' texto.txt

# Buscar líneas que solo contengan espacios
grep -E '^[[:space:]]*$' archivo.txt

# Validar hexadecimal
grep -E '^[[:xdigit:]]+$' colores.txt

Cuantificadores

Los cuantificadores controlan cuántas veces debe aparecer un elemento:

  • *: Cero o más. a*
  • +: Uno o más. a+ (en ERE), a\+ (en BRE)
  • ?: Cero o uno. a? (en ERE), a\? (en BRE)
  • {n}: Exactamente n. a{3} (en ERE), a\{3\} (en BRE)
  • {n,}: n o más. a{3,} (en ERE), a\{3,\} (en BRE)
  • {,m}: Hasta m. a{,5} (en ERE), a\{,5\} (en BRE)
  • {n,m}: Entre n y m. a{2,4} (en ERE), a\{2,4\} (en BRE)

Greedy vs. Lazy (codicioso vs. perezoso):

Por defecto, los cuantificadores son greedy (codiciosos): intentan coincidir con la mayor cantidad de texto posible.

# Greedy: .* captura todo hasta el último >
echo "<h1>Título</h1><p>Párrafo</p>" | grep -oP '<.*>'
# → <h1>Título</h1><p>Párrafo</p>

# Lazy: .*? captura lo mínimo hasta el primer >
echo "<h1>Título</h1><p>Párrafo</p>" | grep -oP '<.*?>'
# → <h1>
# → </h1>
# → <p>
# → </p>

El modo lazy solo está disponible en PCRE (grep -P). En ERE y BRE no hay lazy, pero te puedes ingeniar con una clase negada:

# En ERE, imitar lazy con una clase negada
echo "<h1>Título</h1><p>Párrafo</p>" | grep -oE '<[^>]*>'
# → <h1>
# → </h1>
# → <p>
# → </p>

Anclas y límites de palabra

Las anclas no coinciden con caracteres, sino con posiciones en el texto:

  • ^: Inicio de línea
  • $: Final de línea
  • \<: Inicio de palabra (BRE/ERE)
  • \>: Final de palabra (BRE/ERE)
  • \b: Límite de palabra (PCRE)
  • \B: No límite de palabra (PCRE)
# Líneas que empiezan por "Error"
grep '^Error' log.txt

# Líneas que terminan con punto
grep '\\.$' frases.txt

# Palabra completa "error" (no "error404" ni "supererror")
grep '\\<error\\>' log.txt       # BRE
grep -E '\\<error\\>' log.txt    # ERE también soporta \< \>
grep -P '\\berror\\b' log.txt    # PCRE

# "error" dentro de una palabra (solo PCRE)
grep -P '\\Berror\\B' log.txt    # "supererror", "error404" → NO

Grupos, captura y backreferences

Paréntesis de captura ( ):

Los paréntesis agrupan partes del patrón y capturan el texto coincidente para usarlo después.

# En ERE: (patrón)
grep -E '(ERROR|WARNING|INFO): .+' log.txt

# Captura con sed
echo "Nombre: Juan Pérez" | sed -E 's/Nombre: ([A-Za-z]+) ([A-Za-z]+)/Apellido: \2, Nombre: \1/'
# → Apellido: Pérez, Nombre: Juan

Grupos sin captura (?:) en PCRE:

# (?:patrón) agrupa sin capturar — útil para alternancia sin ensuciar BASH_REMATCH
grep -P '(?:ERROR|WARNING): \\d+' log.txt

Backreferences \\1 a \\9:

Te permiten referirte a texto capturado previamente en el mismo patrón.

# Palabras repetidas (errores típicos de escritura)
grep -E '\\<([A-Za-z]+) \\1\\>' texto.txt
# Coincide con "muy muy", "casa casa", etc.

# Encontrar etiquetas HTML emparejadas
grep -P '<(\\w+)>.*?</\\1>' pagina.html
# Coincide con <b>texto</b>, <i>texto</i>, etc.

Backreferences en el reemplazo de sed:

# \1, \2, etc. en la parte de reemplazo
sed -E 's/([^,]+),([^,]+)/\2 \1/' datos.csv
# Pérez,Juan → Juan Pérez

# \0 es la coincidencia completa
sed -E 's/[0-9]+/(\0)/g' numeros.txt
# "42 manzanas" → "(42) manzanas"

Lookahead y Lookbehind (PCRE)

Las aserciones de longitud cero (lookaround) verifican una condición sin consumir caracteres. Esto te puede ahorrar más de un disgusto cuando necesitas validar contexto sin incluirlo en la coincidencia.

Lookahead positivo (?=...): Verifica que después hay algo.

# ERROR seguido de un número
grep -P 'ERROR(?= \\d+)' log.txt
# Coincide con "ERROR" solo si va seguido de espacio + dígitos

Lookahead negativo (?!...): Verifica que después NO hay algo.

# ERROR no seguido de "404"
grep -P 'ERROR(?! 404)' log.txt

Lookbehind positivo (?<=...): Verifica que antes hay algo.

# El texto después de "ERROR: "
grep -P '(?<=ERROR: ).*' log.txt

Lookbehind negativo (?<!...): Verifica que antes NO hay algo.

# Números que NO están precedidos por $
grep -P '(?<!\\$)[0-9]+(\\.[0-9]+)?' factura.txt

Limitaciones: El lookbehind en PCRE requiere que el patrón sea de longitud fija. No puedes hacer (?<=\\d+), pero sí (?<=\\d{3}).

Ejemplos prácticos reales

Validar email

#!/bin/bash

validar_email() {
    local email="$1"
    # Patrón simplificado (el 100% preciso requeriría RFC 5322)
    local patron='^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$'

    if [[ "$email" =~ $patron ]]; then
        echo "$email es válido"
        return 0
    else
        echo "$email NO es válido"
        return 1
    fi
}

# Pruebas
validar_email "usuario@atareao.es"        # válido
validar_email "user.name+tag@domain.co.uk" # válido
validar_email "usuario@dominio"            # inválido (sin TLD)
validar_email "@atareao.es"                # inválido (sin usuario)

✍️ Explicación del patrón: ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$

  • ^ — Inicio de cadena
  • [a-zA-Z0-9._%+-]+ — Nombre de usuario: letras, dígitos y caracteres especiales
  • @ — Arroba literal
  • [a-zA-Z0-9.-]+ — Dominio
  • \. — Punto literal antes del TLD
  • [a-zA-Z]{2,} — TLD de mínimo 2 letras
  • $ — Final de cadena
  • Es un patrón simplificado. Para validar según RFC 5322, se necesita algo mucho más complejo

Extraer direcciones IP

#!/bin/bash

extraer_ips() {
    local archivo="$1"
    local patron='([0-9]{1,3}\\.){3}[0-9]{1,3}'

    # Primero validar que cada octeto esté entre 0-255
    grep -oP "$patron" "$archivo" | while read -r ip; do
        local IFS='.'
        read -r o1 o2 o3 o4 <<< "$ip"
        if (( o1 <= 255 && o2 <= 255 && o3 <= 255 && o4 <= 255 )); then
            echo "$ip"
        fi
    done
}

# Versión mejorada: función completa con validación
extraer_ips_validas() {
    local archivo="$1"
    local patron_bruto
    patron_bruto='\\b((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\b'

> **✍️ Explicación del patrón:** `((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])`
>
> - `` — Límite de palabra (no capturar IPs dentro de números más largos)
> - `(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])` — Octeto válido entre 0 y 255:
>   - `25[0-5]` — Números 250-255
>   - `2[0-4][0-9]` — Números 200-249
>   - `1[0-9]{2}` — Números 100-199
>   - `[1-9]?[0-9]` — Números 0-99
> - `\.` — Punto literal (separador)
> - `{3}` — El grupo del octeto + punto se repite 3 veces
> - El patrón del octeto se repite una cuarta vez (sin el punto final)

    grep -oP "$patron_bruto" "$archivo" | sort -u
}

Parsear logs de servidor web

#!/bin/bash
# parse_access_log.sh — Extrae información de access.log de Apache/Nginx

# Formato: 192.168.1.1 - - [15/Jan/2024:10:30:45 +0000] "GET /index.html HTTP/1.1" 200 2326

analizar_log() {
    local log_file="$1"

    # Patrón para log combinado de Apache/Nginx
    local patron='^([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+) [^ ]+ [^ ]+ \\[([^\\]]+)\\] "([A-Z]+) ([^ ]+) [^"]+" ([0-9]+) ([0-9]+)'

> **✍️ Explicación del patrón:** `^([0-9]+) [^ ]+ [^ ]+ \[([^\]]+)\] "([A-Z]+) ([^ ]+) [^"]+" ([0-9]+) ([0-9]+)`
>
> - `^([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)` — Grupo 1: IP del cliente
> - ` [^ ]+ [^ ]+` — Dos campos separados por espacio (ident remoto y usuario autenticado)
> - ` \[([^\]]+)\]` — Grupo 2: fecha/hora entre corchetes
> - ` "([A-Z]+)` — Grupo 3: método HTTP (GET, POST, etc.)
> - ` ([^ ]+)` — Grupo 4: ruta solicitada
> - ` [^"]+"` — Protocolo HTTP (lo ignoramos)
> - ` ([0-9]+)` — Grupo 5: código de estado HTTP
> - ` ([0-9]+)` — Grupo 6: bytes transferidos

    while IFS= read -r linea; do
        if [[ "$linea" =~ $patron ]]; then
            local ip="${BASH_REMATCH[1]}"
            local fecha="${BASH_REMATCH[2]}"
            local metodo="${BASH_REMATCH[3]}"
            local ruta="${BASH_REMATCH[4]}"
            local codigo="${BASH_REMATCH[5]}"
            local bytes="${BASH_REMATCH[6]}"

            if (( codigo >= 400 )); then
                echo "Error $codigo: $metodo $ruta desde $ip [$fecha]"
            fi
        fi
    done < "$log_file"
}

Sanitizar input de usuario

#!/bin/bash
# sanitizar.sh — Limpia input de usuario para evitar inyección

sanitizar_comando() {
    local input="$1"
    # Eliminar todo lo que no sea alfanumérico, guión, punto o espacio
    echo "$input" | sed -E 's/[^[:alnum:]._ -]//g'

> **✍️ Explicación del patrón:** `[^[:alnum:]._ -]`
>
> - `[^...]` — Clase negada: coincide con caracteres que NO estén en la lista
> - `[:alnum:]` — Clase POSIX para alfanuméricos (letras y dígitos)
> - `._ -` — Punto, guión bajo, espacio y guión también permitidos
> - `g` — Global: reemplaza todas las ocurrencias
> - El resultado: elimina todo lo que no sea letra, dígito, punto, guión, guión bajo o espacio
}

sanitizar_ruta() {
    local input="$1"
    # Eliminar caracteres peligrosos en rutas
    echo "$input" | sed -E 's/[^a-zA-Z0-9_\\/.-]//g'

> **✍️ Explicación del patrón:** `[^a-zA-Z0-9_/.-]`
>
> - `[^...]` — Clase negada: elimina lo que no esté en la lista
> - `a-zA-Z0-9` — Letras mayúsculas, minúsculas y dígitos
> - `_/.-` — Guión bajo, barra, punto y guión también permitidos
> - Útil para sanitizar rutas de archivo eliminando caracteres peligrosos como `;`, `|`, `` ` ``
}

sanitizar_nombre_usuario() {
    local input="$1"
    # Solo letras, números, guión bajo y punto
    if [[ "$input" =~ ^[a-zA-Z0-9._-]{3,32}$ ]]; then

> **✍️ Explicación del patrón:** `^[a-zA-Z0-9._-]{3,32}$`
>
> - `^...$` — La cadena COMPLETA debe coincidir (nada antes ni después)
> - `[a-zA-Z0-9._-]` — Solo letras, dígitos, punto, guión bajo y guión
> - `{3,32}` — Entre 3 y 32 caracteres (longitud mínima y máxima)
> - Esto es un **rechazo** en lugar de una limpieza: si no cumple, la función retorna error
>
        echo "$input"
        return 0
    fi
    return 1
}

Rendimiento de expresiones regulares

Las expresiones regulares pueden ser muy rápidas o catastróficamente lentas. Algunas consideraciones:

Catastrophic backtracking: Ocurre cuando un patrón con cuantificadores anidados encuentra múltiples formas de coincidir parcialmente, causando una explosión combinatoria.

# Peligro: (a+)+b puede ser exponencial
grep -P '(a+)+b' archivo.txt

# Seguro: equivalente pero sin anidamiento
grep -P 'a+b' archivo.txt

# Peligro: (.+)* también es exponencial
grep -P '(.+)*error' log.txt

# Seguro: especifica mejor el patrón
grep -P '[a-z ]*error' log.txt

Reglas de oro para patrones rápidos:

  1. Usa anclas (^, $) cuando sea posible: reducen drásticamente el espacio de búsqueda
  2. Prefiere clases negadas sobre el punto: [^>]* es más rápido que .*?
  3. Evita cuantificadores anidados como (a+)+, (.*)*, (.+)+
  4. Usa el cuantificador más específico: {3} en vez de + si sabes cuántos
  5. Coloca los patrones más restrictivos al principio de la alternancia: (ERROR|.) es mejor que (.|ERROR)
  6. Usa BRE cuando el patrón es simple: el motor DFA es más rápido que NFA

Funciones reutilizables

Función: regex_match

Verifica si una cadena coincide completamente con un patrón.

# Uso: regex_match <cadena> <patrón>
# Retorna: 0 si coincide, 1 si no
regex_match() {
    local string="$1"
    local pattern="$2"

    if [[ "$string" =~ $pattern ]]; then
        return 0
    fi
    return 1
}

> **✍️ Sobre el patrón en esta función:**
> La función `regex_match` no tiene un patrón fijo, recibe el patrón como argumento. La gracia está en que usa `[[ $string =~ $pattern ]]` con una variable, que es la forma correcta de pasar patrones dinámicos (evita problemas de escaping y comillas).

# Ejemplo de uso
# if regex_match "usuario@email.com" '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$'; then
#     echo "Email válido"
# fi

Función: regex_extract

Extrae las partes de una cadena que coinciden con los grupos de captura.

# Uso: regex_extract <cadena> <patrón> [separador]
# Retorna: imprime los grupos separados por el separador (defecto: tab)
regex_extract() {
    local string="$1"
    local pattern="$2"
    local separator="${3:-$'\\t'}"

    if [[ "$string" =~ $pattern ]]; then
        local i
        # BASH_REMATCH[0] es la coincidencia completa
        for ((i = 1; i < ${#BASH_REMATCH[@]}; i++)); do
            [[ $i -gt 1 ]] && echo -n "$separator"
            echo -n "${BASH_REMATCH[$i]}"
        done
        echo
        return 0
    fi
    return 1
}

> **✍️ Sobre los patrones en `regex_extract`:** Esta función recibe el patrón como argumento, pero el mecanismo es el mismo que `[[ =~ ]]`. La variable `$pattern` se pasa sin comillas dentro de `[[ ]]`, lo que permite que Bash lo interprete como regex. Luego itera sobre `BASH_REMATCH` empezando en índice 1 (saltándose la coincidencia completa en índice 0).

# Ejemplo de uso
# linea="2024-01-15 ERROR: timeout en conexión"
# regex_extract "$linea" '^([0-9-]+) ([A-Z]+): (.+)$'
# → 2024-01-15    ERROR   timeout en conexión

Función: regex_replace

Reemplaza todas las ocurrencias de un patrón en una cadena.

# Uso: regex_replace <cadena> <patrón> <reemplazo>
regex_replace() {
    local string="$1"
    local pattern="$2"
    local replacement="$3"

    # Usar sed con ERE para el reemplazo
    sed -E "s/$pattern/$replacement/g" <<< "$string"
}

> **✍️ Sobre el patrón en `regex_replace`:** Esta función delega en `sed -E`. El patrón se pasa como parte de la expresión `s/patrón/reemplazo/g`. Al usar `-E`, sed interpreta el patrón como ERE (Extended Regular Expressions), lo que permite usar `+`, `?`, `{`, `(`, `)` sin escapar. La flag `g` hace el reemplazo global (todas las ocurrencias).

# Ejemplo de uso
# regex_replace "Hola Juan, adiós Juan" "Juan" "María"
# → Hola María, adiós María

Función: validate_email

Valida una dirección de correo electrónico.

# Uso: validate_email <email>
# Retorna: 0 si es válido, 1 si no
validate_email() {
    local email="$1"

    # Patrón basado en RFC 5322 simplificado
    local patron='^[a-zA-Z0-9.!#$%&'"'"'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\\.[a-zA-Z]{2,}$'

> **✍️ Explicación del patrón:** `^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$`
>
> - Parte local: `[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+` — Muchos más caracteres permitidos que el patrón simple
> - `(?:...)` — Grupos sin captura: agrupan sin guardar en BASH_REMATCH
> - `[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?` — Cada subdominio: empieza y termina con alfanumérico, máximo 63 caracteres
> - `(?:\.[a-zA-Z0-9]...)`* — Cero o más subdominios adicionales
> - `\.[a-zA-Z]{2,}$` — TLD de mínimo 2 letras
> - Este patrón se acerca más al RFC 5322, aunque sigue siendo una simplificación

    if [[ "$email" =~ $patron ]]; then
        echo "$email es válido"
        return 0
    else
        echo "$email NO es válido"
        return 1
    fi
}

Función: extract_ips

Extrae direcciones IP (v4) válidas de un texto.

# Uso: extract_ips <archivo_o_cadena>
extract_ips() {
    local input="$1"
    local patron
    # Patrón que valida cada octeto: 0-255
    patron='\\b((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\b'

> **✍️ Explicación del patrón:** `((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])`
>
> - `` — Límite de palabra (evita coincidencias parciales como `192.168.1.1999`)
> - `(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])` — Un octeto válido (0-255):
>   - `25[0-5]` → 250-255
>   - `2[0-4][0-9]` → 200-249
>   - `1[0-9]{2}` → 100-199
>   - `[1-9]?[0-9]` → 0-99
> - `\.` — Punto literal
> - `{3}` — El grupo (octeto + punto) repetido exactamente 3 veces
> - El patrón del octeto se repite una vez más (el cuarto octeto, sin punto)
> - Este patrón SOLO captura IPs con todos los octetos en rango válido

    if [[ -f "$input" ]]; then
        grep -oP "$patron" "$input" | sort -u
    else
        grep -oP "$patron" <<< "$input" | sort -u
    fi
}

# Ejemplo de uso
# extract_ips access.log
# extract_ips "Servidores: 192.168.1.1, 10.0.0.5, 999.999.999.999 (inválida)"

Errores comunes

Error 1: Olvidar que BASH_REMATCH es 1-indexado

# BASH_REMATCH[0] → toda la coincidencia
# BASH_REMATCH[1] → primer grupo ()
# BASH_REMATCH[2] → segundo grupo ()

Error 2: Usar comillas en el patrón de [[ =~ ]]

# Mal: las comillas hacen que el patrón sea literal
if [[ "$texto" =~ "^[0-9]+$" ]]; then
    echo "Busca literalmente el texto '^[0-9]+$', no es regex"
fi

# Bien: sin comillas
if [[ "$texto" =~ ^[0-9]+$ ]]; then
    echo "Es numérico"
fi

# Bien: guardar el patrón en variable (recomendado para patrones complejos)
patron='^[0-9]+$'
if [[ "$texto" =~ $patron ]]; then
    echo "Es numérico"
fi

Error 3: Mezclar sintaxis BRE y ERE

# Mal: usar () y {} sin escapar en BRE
grep '(error|warning)' log.txt       # No funciona, busca literal "(error|warning)"
grep 'a{3}' archivo.txt              # No funciona, busca literal "a{3}"

# Bien: o escapas en BRE o usas -E
grep '\\(error\\|warning\\)' log.txt    # BRE con escapes
grep -E '(error|warning)' log.txt    # ERE sin escapes

Error 4: Esperar que sed use ERE por defecto

# Mal: sed por defecto usa BRE
sed 's/[0-9]{3}//g' archivo.txt                      # No funciona
sed 's/[0-9]\\{3\\}//g' archivo.txt                    # BRE correcto

# Bien: usar -E para ERE en sed
sed -E 's/[0-9]{3}//g' archivo.txt                   # Funciona

Error 5: No escapar caracteres especiales en sed

# Mal: / es delimitador, . y * son metacaracteres
echo "archivo.txt" | sed 's/.txt/.md/'   # El . coincide con cualquier carácter

# Bien: escapar
echo "archivo.txt" | sed 's/\\.txt/\\.md/'   # . literal

# Alternar delimitador para evitar leaning toothpick con rutas
echo "/usr/local/bin" | sed 's|/usr/local|/opt|'   # Usar | en vez de /

Error 6: Greedy cuando se necesita lazy

# Mal: .* captura hasta el último match, no el primero
echo "<p>Uno</p><p>Dos</p>" | grep -oP '<p>.*</p>'
# → <p>Uno</p><p>Dos</p>  (codicioso: todo entre primer <p> y último </p>)

# Bien: lazy o clase negada
echo "<p>Uno</p><p>Dos</p>" | grep -oP '<p>.*?</p>'
# → <p>Uno</p>
# → <p>Dos</p>

# En ERE (sin lazy), usar clase negada
echo "<p>Uno</p><p>Dos</p>" | grep -oE '<p>[^<]*</p>'

Error 7: No validar IPs correctamente

# Mal: [0-9]{1,3} acepta 999.999.999.999 que NO es una IP válida
grep -oE '[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}' log.txt

# Bien: validar que cada octeto esté entre 0 y 255
grep -oP '\\b((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\b' log.txt

Error 8: Catastrophic backtracking

# Mal: patrones con cuantificadores anidados pueden ser exponenciales
patron='(.+)*ERROR'            # Si falla, prueba todas las divisiones posibles

# Bien: patrones más específicos y sin anidamiento
patron='[A-Za-z0-9 ]*ERROR'   # Especifica qué caracteres esperar

Error 9: Usar [0-9] en vez de [[:digit:]] o \\d

# Mal: [0-9] no cubre dígitos de otros sistemas numéricos
grep '[0-9]' archivo.txt       # Solo 0-9 ASCII

# Mejor: clases POSIX portables
grep '[[:digit:]]' archivo.txt  # Respeta localización

# Con PCRE
grep -P '\\d' archivo.txt        # Si necesitas PCRE

# En scripts que se ejecutarán en diferentes locales, usa clases POSIX

Error 10: No considerar que grep -o puede dar falsos positivos con patrones parciales

# Mal: grep -o extrae coincidencias parciales sin validación de límites
echo "Mi IP es 192.168.1.999" | grep -oE '[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}'
# → 192.168.1.999 (inválida como IP, pero el patrón la acepta)

# Bien: usar patrones con límites de palabra y cuantificadores específicos
echo "Mi IP es 192.168.1.999" | grep -oP '\\b((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\b'
# → (no encuentra nada, porque 999 no es válido)

# Además: siempre validar semánticamente después de la regex

Resumen

En este capítulo has visto todo lo necesario para dominar las expresiones regulares en Bash: desde los fundamentos de BRE y ERE hasta técnicas avanzadas con PCRE, pasando por las herramientas clave grep, sed, awk y el operador nativo [[ =~ ]].

Reglas de oro:

  1. Conoce tu motor: BRE escapa ()+{}|, ERE no. Usa -E en grep y sed para ERE.
  2. Sin comillas en [[ =~ ]]: El patrón sin comillas es regex; con comillas es literal.
  3. BASH_REMATCH es 1-indexado: [0] es la coincidencia completa, [1] el primer grupo.
  4. Usa clases POSIX: [[:digit:]], [[:alpha:]], etc. son portables y respetan la localización.
  5. Evita backtracking catastrófico: No anides cuantificadores como (a+)+ o (.*)*.
  6. Prefiere patrones específicos: [^>]* es mejor que .*? (más rápido y portable).
  7. Valida semánticamente después de la regex: La regex confirma el formato; la lógica adicional confirma el significado.
  8. Usa variables para patrones complejos: Guarda el patrón en una variable para evitar problemas de escaping.
  9. Prueba en regex101.com: Antes de integrar un patrón complejo, pruébalo interactivamente.
  10. Documenta tus patrones: Un regex complejo sin comentarios es ilegible para otros (y para ti mismo en 3 meses).

Las expresiones regulares son una de las habilidades más rentables que puedes aprender en programación. Lo que antes requería decenas de líneas de lógica imperativa se resuelve con un patrón bien escrito. Domínalas y tu capacidad para procesar texto se multiplicará.

En el próximo capítulo te acompañaré en el fascinante mundo de las redirecciones y descriptores de archivo en Bash. Verás cómo controlar el flujo de datos entre comandos, archivos y dispositivos.


Más información,

  • Páginas de manual
    • man 7 regex — Documentación general sobre expresiones regulares POSIX
    • man 7 re_format — Formato de expresiones regulares en BSD/macOS
    • man grep — Todas las opciones de grep
    • man sed — Comandos de sed, flags de sustitución
    • man awk — El manual completo de awk, incluyendo funciones de regex
    • man bash — Busca la sección [[ expression ]] para =~ y BASH_REMATCH
  • Enlaces externos
  • Herramientas relacionadas
    • grep: Buscador de patrones en texto (estándar POSIX)
    • sed: Editor de flujo para búsqueda y reemplazo
    • awk: Lenguaje de procesamiento de texto con regex integrado
    • grep -P: Modo PCRE (Perl Compatible Regex) si está compilado
    • ag (the silver searcher): Alternativa moderna a grep, más rápida
    • rg (ripgrep): La alternativa más rápida a grep, con regex PCRE por defecto
    • perl: El lenguaje que definió PCRE; potente para regex complejas
    • regex101.com: Probador interactivo de regex con explicación paso a paso

Deja una respuesta