Detective para los bots de IA

·

Los logs nunca mienten, pero tampoco cuentan toda la verdad a simple vista. Cada línea de acceso es una huella, una pisada en el pasillo de tu servidor. El problema es que ahora, además de los visitantes humanos y los rastreadores clásicos, hay una plaga nueva: los bots de inteligencia artificial.

Y no vienen a comprar. Vienen a entrenar.

La puerta giratoria

Gestiono centenares de sitios web. En los últimos meses, el tráfico «no humano» ha explotado. No es solo Googlebot ni Bingbot. Ahora hay decenas de actores (OpenAI, Anthropic, ByteDance, Perplexity, Amazon, Apple, Meta…) rastreando todo lo que publicamos para alimentar sus modelos. Sin aviso previo, sin contrato, sin compensación.

El coste no es solo ético. Es eléctrico y digital: ancho de banda, CPU, almacenamiento de logs. Cada petición que sirves a un bot de IA es una petición que no sirves a un lector real. Y en una infraestructura propia, cada giga cuenta.

Pero el primer paso para defenderte es saber quién está en tu puerta.

User-Agent: el carné fácil de falsificar

La solución ingenua es mirar el User-Agent. Y sí, es útil: GPTBot, ClaudeBot, Bytespider… todos se anuncian. Pero un User-Agent se puede escribir con curl -A. He visto «Googlebot» que venía de una VM en Google Cloud sin relación con los rangos oficiales de búsqueda. He visto peticiones sin User-Agent que resultaban ser SEO tools agresivas.

Confiar en la cadena de texto que envía el cliente es como creerle al lobo porque lleva puesta la piel de la abuela.

Necesitaba algo más forense.

Un script que no depende de nada

Así nació detect-ai-bots.sh. La filosofía es simple: zero dependencies. No quiero instalar Python, ni Elasticsearch, ni un parser de logs corporativo. Solo bash, awk y lo que ya existe en cualquier servidor Debian/Ubuntu. Opcionalmente, jq si quieres enriquecimiento, pero el núcleo funciona con lo que viene en la caja.

El script lee logs en formato combined (Apache o Angie/Nginx), comprimidos o no, acepta globs del shell y hace todo el trabajo en una sola pasada de awk. Incluye hasta un parser de fechas a epoch UTC sin llamar al binario date, porque en logs grandes, lanzar una subshell por línea es suicidio de rendimiento.

La ventana de tiempo es configurable. Por defecto mira las últimas 24 horas, pero puedes pedirle las últimas dos horas, una semana, lo que necesites.

Lo que descubre

Agrupa las peticiones en categorías. No solo «IA» genérico, sino quién exactamente:

  • IA: OpenAI (GPTBot, ChatGPT-User, OAI-SearchBot)
  • IA: Anthropic (ClaudeBot, Claude-Web, Anthropic-AI)
  • IA: Google (Google-Extended, GoogleOther, CloudVertexBot)
  • IA: ByteDance (Bytespider)
  • IA: Perplexity, Amazon, Apple, Meta, Cohere, Diffbot, You.com…
  • Y luego los buscadores tradicionales, SEO tools (Ahrefs, Semrush, Majestic), monitorización y el resto.

Puedes pedirle el detalle por User-Agent exacto para ver variantes. O pedirle el top de IP, filtrado por categoría. Esto es clave: si descubro que dos IP de un rango de DigitalOcean se hacen pasar por lectores humanos, pero hacen mil peticiones a la hora, tengo un candidato para el firewall.

El truco sucio: PTR y forward-confirm

Aquí es donde el script se pone serio. Con la flag -r, no solo hace resolución inversa (PTR) de cada IP. Eso cualquiera lo hace. Lo que hace es forward-confirm: toma el nombre que devuelve el PTR y resuelve todos sus registros A/AAAA con getent ahosts. Si ninguno coincide con la IP original, el bot está mintiendo.

Es la diferencia entre ver un carné y llamar a la central para verificarlo.

He pillado «Googlebot» que venía de un hostname de AWS. He pillado «Bingbot» que resultaba ser un scraper residencial. El forward-confirm es barato, rápido y usa solo NSS local. Sin API de terceros. Sin cuotas.

Cuando necesitas contexto geopolítico

Con la flag -e, enriquece cada IP consultando ip.robotstxt.es (nuesro propio servicio de geolocalización). Obtienes país, ciudad, organización y ASN. De repente, ese «bot de monitorización» resulta estar en un datacenter de Rusia. Ese «lector humano» es un nodo de Hetzner.

No es paranoia si realmente están rastreando tu contenido.

Ejemplos que uso a diario

Un vistazo rápido a las últimas dos horas:

bash detect-ai-bots.sh -h 2 /var/log/angie/access.log

Ver el detalle de qué variantes de Claude me han visitado:

bash detect-ai-bots.sh -h 24 -d /var/log/angie/*.log

Top 50 IP del último día, verificando PTR y geolocalizando:

bash detect-ai-bots.sh -t 50 -e -r /var/log/angie/access.log

Y mi favorito: los que se hacen pasar por humanos sin decir nada.

bash detect-ai-bots.sh -t 20 -c "Sin User-Agent" -e -r /var/log/angie/access.log

De la detección a la acción

Saber quién entra no es fetichismo de logs. Es soberanía digital. Con esta información puedo:

  • Bloquear rangos de IP específicos en el firewall del nodo o del VPS.
  • Ajustar robots.txt con reglas quirúrgicas (y servirlo como 404 para los que lo ignoran).
  • Identificar fugas: ¿por qué un bot de IA pide una URL antigua que no está enlazada en ningún sitio?
  • Dimensionar recursos: si el 40% de mi tráfico es IA, quizá necesito una caché más agresiva o un rate limit específico.

Conclusión

No podemos detener la marea de los modelos de lenguaje. Pero sí podemos saber quién está en nuestra casa, cuándo y con qué excusa. Este script es mi lupa en el suelo del servidor. No necesita instalar nada, no envía datos a terceros (salvo que uses el enriquecimiento opcional) y me da la verdad sin maquillaje.

Porque en una infraestructura propia, la ignorancia no es felicidad. Es un gasto en la factura del hosting.

El script

#!/usr/bin/env bash
#
# detect-ai-bots.sh
# Analiza logs de acceso (Apache/Nginx, formato combined) y cuenta peticiones
# de bots/rastreadores de IA y otros bots en una ventana de tiempo reciente.
#
# Uso:
#   detect-ai-bots.sh [-h HORAS] [-d] [-t N] [-c CATEGORIA] [-e] [-r] ruta_log [ruta_log2 ...]
#
#   -h HORAS      Ventana de tiempo en horas (por defecto: 24)
#   -d            Detalle: lista cada User-Agent con su recuento
#   -t N          Muestra el top N de IPs por número de peticiones
#   -c CATEGORIA  Limita el top de IPs (-t) a una categoría concreta
#                 (p.ej. "Sin User-Agent", "IA: OpenAI", "Otros bots/scripts")
#   -e            Enriquece el top de IPs con geolocalización/ASN vía
#                 ip.robotstxt.es (requiere 'jq' y acceso a internet)
#   -r            Añade resolución inversa (PTR) de cada IP y verifica si
#                 hace forward-confirm (detecta bots que falsifican su UA
#                 pero no tienen el PTR/red que dicen tener, p.ej. "Googlebot"
#                 que en realidad viene de una VM de Google Cloud)
#
# Acepta ficheros .log y .log.gz, y rutas con globs (las expande el propio shell).

set -euo pipefail

HOURS=24
SHOW_DETAIL=0
TOP_IPS=0
CATFILTER=""
ENRICH=0
RDNS=0

usage() {
    cat >&2 <<EOF
Uso: $(basename "$0") [-h HORAS] [-d] [-t N] [-c CATEGORIA] [-e] [-r] ruta_log [ruta_log2 ...]

  -h HORAS      Ventana de tiempo a analizar, en horas (por defecto: 24)
  -d            Muestra el detalle por User-Agent dentro de cada categoría
  -t N          Muestra el top N de IPs con más peticiones
  -c CATEGORIA  Limita el top de IPs (-t) a una categoría concreta. El nombre
                debe coincidir con el que aparece en "Peticiones por categoría"
                (entre comillas si tiene espacios), p.ej.:
                  "Sin User-Agent", "IA: OpenAI", "IA: ByteDance",
                  "Otros bots/scripts", "Humano / navegador"
  -e            Enriquece el top de IPs con país/organización/ASN vía
                ip.robotstxt.es (requiere 'jq' y salida a internet)
  -r            Añade PTR (resolución inversa) de cada IP y comprueba el
                forward-confirm (PTR -> resuelve de vuelta a la misma IP).
                Útil para detectar bots que dicen ser Googlebot/Bingbot
                pero en realidad vienen de una VM en la nube.

Ejemplos:
  $(basename "$0") /var/log/nginx/access.log
  $(basename "$0") -h 12 -d /var/log/nginx/*.log
  $(basename "$0") -t 20 /var/log/apache2/*access*.log /var/log/apache2/*access*.log.1.gz
  $(basename "$0") -t 50 -c "Sin User-Agent" -e -r /var/log/nginx/*.log
EOF
    exit 1
}

while getopts "h:dt:c:er" opt; do
    case "$opt" in
        h) HOURS="$OPTARG" ;;
        d) SHOW_DETAIL=1 ;;
        t) TOP_IPS="$OPTARG" ;;
        c) CATFILTER="$OPTARG" ;;
        e) ENRICH=1 ;;
        r) RDNS=1 ;;
        *) usage ;;
    esac
done
shift $((OPTIND - 1))

if [[ $ENRICH -eq 1 ]] && ! command -v jq >/dev/null 2>&1; then
    echo "Aviso: 'jq' no está instalado, se desactiva el enriquecimiento (-e). Instala con: apt install jq" >&2
    ENRICH=0
fi

[[ $# -eq 0 ]] && usage

FILES=()
for f in "$@"; do
    if [[ -e "$f" ]]; then
        FILES+=("$f")
    else
        echo "Aviso: '$f' no existe, se ignora." >&2
    fi
done

[[ ${#FILES[@]} -eq 0 ]] && { echo "Error: ningún fichero de log válido." >&2; exit 1; }

NOW_EPOCH=$(date -u +%s)
THRESHOLD=$(( NOW_EPOCH - HOURS * 3600 ))

RESULT=$(mktemp)
trap 'rm -f "$RESULT"' EXIT

read_files() {
    for f in "${FILES[@]}"; do
        case "$f" in
            *.gz) zcat -- "$f" 2>/dev/null ;;
            *)    cat  -- "$f" 2>/dev/null ;;
        esac
    done
}

read_files | awk -v threshold="$THRESHOLD" -v show_detail="$SHOW_DETAIL" -v top_ips="$TOP_IPS" -v catfilter="$CATFILTER" '
function mon2num(m,    months, i) {
    split("Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec", months, ",")
    for (i = 1; i <= 12; i++) if (months[i] == m) return i
    return 0
}

# Convierte fecha/hora local + offset de zona a epoch UTC, sin llamar a `date`.
function to_epoch(year, mon, day, hh, mm, ss, tzsign, tzhh, tzmm,    a, y2, m2, jdn, days, offset) {
    a   = int((14 - mon) / 12)
    y2  = year + 4800 - a
    m2  = mon + 12 * a - 3
    jdn = day + int((153 * m2 + 2) / 5) + 365 * y2 + int(y2 / 4) - int(y2 / 100) + int(y2 / 400) - 32045
    days = jdn - 2440588
    offset = tzhh * 3600 + tzmm * 60
    if (tzsign == "-") offset = -offset
    return days * 86400 + hh * 3600 + mm * 60 + ss - offset
}

function categorize(ua,    l) {
    l = tolower(ua)
    if (l == "-" || l == "") return "Sin User-Agent"

    if (index(l,"gptbot") || index(l,"chatgpt-user") || index(l,"oai-searchbot")) return "IA: OpenAI"
    if (index(l,"claudebot") || index(l,"claude-web") || index(l,"claude-searchbot") || index(l,"anthropic-ai")) return "IA: Anthropic"
    if (index(l,"ccbot")) return "IA: Common Crawl"
    if (index(l,"google-extended") || index(l,"googleother") || index(l,"google-cloudvertexbot")) return "IA: Google"
    if (index(l,"bytespider")) return "IA: ByteDance"
    if (index(l,"perplexitybot") || index(l,"perplexity-user")) return "IA: Perplexity"
    if (index(l,"amazonbot")) return "IA: Amazon"
    if (index(l,"applebot-extended")) return "IA: Apple"
    if (index(l,"meta-externalagent") || index(l,"meta-externalfetcher")) return "IA: Meta"
    if (index(l,"cohere")) return "IA: Cohere"
    if (index(l,"diffbot")) return "IA: Diffbot"
    if (index(l,"youbot")) return "IA: You.com"
    if (index(l,"ai2bot")) return "IA: Allen AI"
    if (index(l,"timpibot") || index(l,"imagesiftbot")) return "IA: Otros"
    if (index(l,"omgili") || index(l,"webzio")) return "IA: Omgili/Webz"
    if (index(l,"mistralai")) return "IA: Mistral"
    if (index(l,"magpie-crawler")) return "IA: Magpie"

    if (index(l,"facebookbot") || index(l,"facebookexternalhit")) return "Bot: Meta/Facebook"
    if (index(l,"googlebot")) return "Buscador: Google"
    if (index(l,"bingbot") || index(l,"bingpreview")) return "Buscador: Bing"
    if (index(l,"slurp")) return "Buscador: Yahoo"
    if (index(l,"duckduckbot")) return "Buscador: DuckDuckGo"
    if (index(l,"baiduspider")) return "Buscador: Baidu"
    if (index(l,"yandexbot")) return "Buscador: Yandex"
    if (index(l,"sogou")) return "Buscador: Sogou"
    if (index(l,"exabot")) return "Buscador: Exalead"
    if (index(l,"seznambot")) return "Buscador: Seznam"

    if (index(l,"ahrefsbot")) return "SEO: Ahrefs"
    if (index(l,"semrushbot")) return "SEO: Semrush"
    if (index(l,"mj12bot")) return "SEO: Majestic"
    if (index(l,"dotbot")) return "SEO: Moz"
    if (index(l,"blexbot")) return "SEO: WebMeUp"
    if (index(l,"petalbot")) return "SEO: Huawei Petal"
    if (index(l,"dataforseobot")) return "SEO: DataForSEO"

    if (index(l,"uptimerobot") || index(l,"pingdom") || index(l,"statuscake")) return "Monitorización"

    if (index(l,"bot") || index(l,"spider") || index(l,"crawler") || index(l,"curl") || index(l,"wget") || index(l,"python-requests") || index(l,"scrapy") || index(l,"libwww") || index(l,"go-http-client")) return "Otros bots/scripts"

    return "Humano / navegador"
}

{
    total_lines++

    if (!match($0, /\[[0-3][0-9]\/[A-Za-z]{3}\/[0-9]{4}:[0-2][0-9]:[0-5][0-9]:[0-5][0-9] [+-][0-9]{4}\]/)) {
        unparsed++
        next
    }
    ts = substr($0, RSTART + 1, RLENGTH - 2)

    day     = substr(ts, 1, 2) + 0
    monname = substr(ts, 4, 3)
    year    = substr(ts, 8, 4) + 0
    hh      = substr(ts, 13, 2) + 0
    mm      = substr(ts, 16, 2) + 0
    ss      = substr(ts, 19, 2) + 0
    tzsign  = substr(ts, 22, 1)
    tzhh    = substr(ts, 23, 2) + 0
    tzmm    = substr(ts, 25, 2) + 0

    mon = mon2num(monname)
    if (mon == 0) { unparsed++; next }

    epoch = to_epoch(year, mon, day, hh, mm, ss, tzsign, tzhh, tzmm)
    if (epoch < threshold) { older++; next }

    matched_lines++

    ip = $1

    n = split($0, q, "\"")
    ua = (n >= 2) ? q[n - 1] : "-"
    if (ua == "") ua = "-"

    cat = categorize(ua)
    cat_count[cat]++
    if (show_detail) ua_count[cat SUBSEP ua]++
    if (top_ips > 0) {
        ip_count[ip]++
        ip_cat_count[cat SUBSEP ip]++
    }
}

END {
    for (c in cat_count) print "CAT\t" c "\t" cat_count[c]
    if (show_detail) {
        for (k in ua_count) {
            split(k, parts, SUBSEP)
            print "UA\t" parts[1] "\t" parts[2] "\t" ua_count[k]
        }
    }
    if (top_ips > 0) {
        if (catfilter == "") {
            for (ip in ip_count) print "IP\t" ip "\t" ip_count[ip]
        } else {
            for (k in ip_cat_count) {
                split(k, parts, SUBSEP)
                if (parts[1] == catfilter) print "IP\t" parts[2] "\t" ip_cat_count[k]
            }
        }
    }
    print "META\ttotal_lines\t" total_lines + 0
    print "META\tmatched_lines\t" matched_lines + 0
    print "META\tunparsed\t" unparsed + 0
    print "META\tolder\t" older + 0
}
' > "$RESULT"

get_meta() {
    awk -F'\t' -v k="$1" '$1=="META" && $2==k {print $3}' "$RESULT"
}

TOTAL_LINES=$(get_meta total_lines)
MATCHED=$(get_meta matched_lines)
UNPARSED=$(get_meta unparsed)
OLDER=$(get_meta older)

echo "===================================================================="
echo " Bots / rastreos de IA — ventana de ${HOURS}h — ${#FILES[@]} fichero(s)"
echo "===================================================================="
echo "Líneas leídas en total      : $TOTAL_LINES"
echo "Líneas dentro de la ventana : $MATCHED"
echo "Líneas fuera de la ventana  : $OLDER"
echo "Líneas sin timestamp válido : $UNPARSED"
echo

if [[ "$MATCHED" -eq 0 ]]; then
    echo "No hay peticiones dentro de la ventana de ${HOURS}h."
    exit 0
fi

echo "--- Peticiones por categoría (ventana de ${HOURS}h) ---"
grep '^CAT' "$RESULT" | cut -f2,3 | sort -t$'\t' -k2,2 -rn \
    | awk -F'\t' '{printf "%-28s %10d\n", $1, $2}'

if [[ "$SHOW_DETAIL" -eq 1 ]]; then
    echo
    echo "--- Detalle por User-Agent ---"
    grep '^UA' "$RESULT" | cut -f2,3,4 | sort -t$'\t' -k3,3 -rn \
        | awk -F'\t' '{printf "%-22s %-55s %8d\n", $1, $2, $3}'
fi

lookup_ip() {
    local ip="$1"
    local json
    json=$(curl -s -m 4 "https://ip.robotstxt.es/${ip}/json" 2>/dev/null || true)
    if [[ -z "$json" ]]; then
        printf '%s\t%s\t%s\t%s\n' "-" "-" "-" "-"
        return
    fi
    echo "$json" | jq -r '[(.country // "-"), (.city // "-"), (.org // "-"), (.asn // "-")] | @tsv' 2>/dev/null \
        || printf '%s\t%s\t%s\t%s\n' "-" "-" "-" "-"
}

# PTR (resolución inversa) vía NSS local, sin dependencias externas.
resolve_ptr() {
    local ip="$1"
    local ptr
    ptr=$(getent hosts "$ip" 2>/dev/null | awk '{print $2}' | head -n1)
    [[ -z "$ptr" ]] && ptr="-"
    echo "$ptr"
}

# Forward-confirm: resuelve el PTR de vuelta y comprueba que apunta a la misma IP.
# Sin esto, un PTR a secas no demuestra nada (se puede falsificar/no coincidir).
# Usa 'ahosts' (no 'hosts') para comprobar TODOS los registros A/AAAA del nombre,
# no solo el primero -- nombres con varias IPs (round-robin) darían falso negativo si no.
verify_ptr() {
    local ip="$1" ptr="$2"
    [[ "$ptr" == "-" ]] && { echo "-"; return; }
    if getent ahosts "$ptr" 2>/dev/null | awk '{print $1}' | grep -qx "$ip"; then
        echo "OK"
    else
        echo "NO"
    fi
}

if [[ "$TOP_IPS" -gt 0 ]]; then
    echo
    if [[ -n "$CATFILTER" ]]; then
        echo "--- Top $TOP_IPS IPs — categoría: $CATFILTER (ventana de ${HOURS}h) ---"
    else
        echo "--- Top $TOP_IPS IPs con más peticiones (ventana de ${HOURS}h) ---"
    fi

    if [[ "$ENRICH" -eq 1 || "$RDNS" -eq 1 ]]; then
        header=$(printf "%-22s %10s" "IP" "PETICIONES")
        [[ "$ENRICH" -eq 1 ]] && header+=$(printf "  %-5s %-20s %-30s %-12s" "PAÍS" "CIUDAD" "ORGANIZACIÓN" "ASN")
        [[ "$RDNS" -eq 1 ]]   && header+=$(printf "  %-45s %-8s" "PTR (rDNS)" "CONFIRMA")
        echo "$header"
    fi

    grep '^IP' "$RESULT" | cut -f2,3 | sort -t$'\t' -k2,2 -rn | head -n "$TOP_IPS" \
        | while IFS=$'\t' read -r ip count; do
            if [[ "$ENRICH" -eq 1 || "$RDNS" -eq 1 ]]; then
                line=$(printf "%-22s %10d" "$ip" "$count")
                if [[ "$ENRICH" -eq 1 ]]; then
                    info=$(lookup_ip "$ip")
                    IFS=$'\t' read -r country city org asn <<< "$info"
                    line+=$(printf "  %-5s %-20s %-30s %-12s" "$country" "$city" "$org" "$asn")
                fi
                if [[ "$RDNS" -eq 1 ]]; then
                    ptr=$(resolve_ptr "$ip")
                    confirm=$(verify_ptr "$ip" "$ptr")
                    line+=$(printf "  %-45s %-8s" "$ptr" "$confirm")
                fi
                echo "$line"
            else
                printf "%-22s %10d\n" "$ip" "$count"
            fi
        done
fi

Comments

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *