← Terminal y Línea de Comandos en Linux Bloque 9 · Integración y cierre · Tema 42 de 43

Tema 42

Proyecto: analizar un archivo de registro

Este tema no enseña ningún comando. Es un trabajo, de los que se hacen de verdad con una terminal: hay un archivo de más de cuatro mil líneas, que nadie leyó, y hay preguntas que solo ese archivo puede responder. No se lo puede abrir y leer; hay que interrogarlo. En el tema 24 dijimos que sort | uniq -c | sort -rn era el modismo más valioso de la línea de comandos, y que respondía cualquier pregunta de la forma «¿cuál es el más frecuente?». Aquí lo vamos a usar una docena de veces, junto con todo lo demás, para encontrar tres cosas que le pasaron a un servidor y que nadie había notado.

1. El encargo, y el método

Una tienda en línea que vende mates y termos tiene su sitio en un servidor Linux. El martes a la mañana llega un mensaje de la dueña:

El pedido

«Ayer, lunes, me escribieron varios clientes diciendo que no podían terminar la compra. A la tarde andaba todo bien. ¿Me puedes decir qué pasó, y a cuántos les pasó? Y de paso, cuéntame cómo anduvo el sitio el fin de semana largo.»

El servidor no tiene panel de estadísticas. Tiene lo que tienen todos: un archivo de registro, en el que el servidor web anotó una línea por cada pedido que recibió. Como vimos en el tema 39, ese es uno de los registros que las aplicaciones llevan por su cuenta, fuera del journal. Lo copiamos a nuestro equipo con scp, y empezamos.

Un análisis de este tipo sigue siempre el mismo orden, y es el que le da su estructura al tema:

  1. Reconocer. Qué tamaño tiene el archivo, qué forma tienen sus líneas, qué significa cada campo. mirar
  2. Medir lo general. Qué período cubre, cuántos pedidos, de cuántos clientes, con qué resultados. contar
  3. Seguir lo que llama la atención. Cada número raro es una pregunta nueva. investigar
  4. Limpiar. Separar lo que distorsiona, y volver a medir. depurar
  5. Informar. Dejar las consultas en un script, y las conclusiones en palabras. entregar

El tercer paso es el que no se puede planificar, y el más interesante. Nadie sabe de antemano qué va a encontrar. Las preguntas buenas salen de mirar las respuestas a las preguntas simples.

2. Conseguir el archivo

Para que todos trabajemos sobre los mismos datos y obtengamos los mismos números, el registro lo fabrica un script. No usa el azar: aplica una fórmula, y produce siempre exactamente el mismo archivo. Crea un directorio para el proyecto, guarda allí el script con el nombre generar-log.sh —con nano, copiando y pegando— y ejecútalo.

~/practica/proyecto/generar-log.sh
#!/bin/bash
# generar-log.sh: fabrica un registro de servidor web para practicar.
# Uso: ./generar-log.sh > acceso.log
# Produce siempre el mismo archivo: no usa el azar, sino una fórmula.

awk '
function azar(n) { x = (x * 75 + 74) % 65537; return x % n }

function linea(ip, h, m, s, metodo, ruta, estado, bytes, origen, agente) {
    printf "%s - - [%02d/Oct/2026:%02d:%02d:%02d -0300] \"%s %s HTTP/1.1\" %d %d \"%s\" \"%s\"\n",
        ip, dia, h, m, s, metodo, ruta, estado, bytes, origen, agente
}

BEGIN {
    x = 41
    sitio = "https://tienda.example"
    nr = split("/ / / / /productos /productos /productos /productos/12 /productos/42 /productos/42 " \
               "/productos/7 /carrito /carrito /nosotros /contacto /blog /blog/ /blog/ofertas " \
               "/buscar?q=mate /buscar?q=termo /css/estilo.css /css/estilo.css /js/app.js /js/app.js " \
               "/img/logo.png /img/logo.png /img/logo-viejo.png /api/pedidos /api/pedidos " \
               "/descargas/catalogo.pdf /login /favicon.ico", rutas, " ")
    split("6 4 3 3 4 6 14 30 55 80 95 100 85 70 75 90 95 85 70 60 48 36 22 12", porhora, " ")
    na = split("Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0!" \
               "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/129.0 Safari/537.36!" \
               "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile Safari/604.1!" \
               "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/129.0 Mobile Safari/537.36", agentes, "!")
    nm = split("/wp-login.php /.env /phpmyadmin/ /admin /.git/config /wp-admin/ /backup.zip", malas, " ")

    for (dia = 9; dia <= 12; dia++) {
        for (h = 0; h < 24; h++) {
            n = porhora[h + 1]
            if (dia == 10 || dia == 11) n = int(n * 6 / 10)
            for (i = 0; i < n; i++) {
                seg = int(i * 3600 / n) + azar(int(3600 / n))
                m = int(seg / 60); s = seg % 60
                k = int(azar(40) * (azar(30) + 10) / 40)
                ip = (k % 2 ? "203.0.113." : "198.51.100.") (10 + k)
                agente = agentes[k % na + 1]
                ruta = rutas[azar(nr) + 1]
                metodo = "GET"; estado = 200; origen = "-"

                robot = (azar(25) == 0)
                if (robot) {
                    ip = "66.249.66.1"
                    agente = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
                    if (azar(3) == 0) ruta = "/robots.txt"
                    else if (ruta ~ /pedidos|carrito|login/) ruta = "/productos"
                }
                caida = (dia == 12 && h == 10 && m >= 5 && m < 40)
                if (caida && !robot && azar(3) > 0) ruta = "/api/pedidos"
                bytes = 900 + length(ruta) * 317 + azar(400)
                if (ruta == "/robots.txt") bytes = 68

                if (ruta ~ /^\/(css|js|img)\//) {
                    origen = sitio "/"
                    if (azar(8) == 0) { estado = 304; bytes = 0 }
                }
                if (ruta == "/img/logo-viejo.png") { estado = 404; bytes = 162; origen = sitio "/nosotros" }
                if (ruta == "/blog/") { estado = 301; bytes = 178 }
                if (ruta == "/descargas/catalogo.pdf") bytes = 2411724
                if (ruta == "/api/pedidos") {
                    metodo = "POST"; estado = 201; bytes = 87; origen = sitio "/carrito"
                    if (caida) { estado = 500; bytes = 21 }
                }
                linea(ip, h, m, s, metodo, ruta, estado, bytes, origen, agente)
            }
            if (dia == 10 && h == 22)
                for (q = 0; q < 60; q++)
                    linea("192.0.2.146", h, 14 + int(q / 4), q * 13 % 60, "GET", malas[azar(nm) + 1], 404, 162, "-", "Mozilla/5.0 zgrab/0.x")
            if (dia == 11 && h == 3)
                for (q = 0; q < 480; q++)
                    linea("198.51.100.77", h, 10 + int(q / 12), q % 12 * 5, "POST", "/login", 401, 43, "-", "python-requests/2.31.0")
        }
    }
}' | LC_ALL=C sort -s -k4,4

No hace falta entenderlo para seguir el tema: es un programa de awk como los del tema 30, con bucles que pertenecen al curso siguiente. Alcanza con reconocer su forma, que es la del tema 41: primera línea, encabezado con el modo de uso, y la salida por el canal estándar, para que quien lo llame decida dónde guardarla.

terminal
$ mkdir -p ~/practica/proyecto && cd ~/practica/proyecto
$ nano generar-log.sh                  # pegar el script, guardar y salir
$ chmod +x generar-log.sh
$ ./generar-log.sh > acceso.log
$ wc -l acceso.log
4202 acceso.log
$ sha256sum acceso.log
de0b475be947ac4f1107067872fba93e646074cc89e46f28f1a5258dc2f0699e  acceso.log

Las dos últimas líneas son la verificación. Si wc -l responde 4202 y la suma coincide con la de arriba —el sha256sum que presentamos en el tema 40—, tu archivo es idéntico al de este tema, byte por byte, y todos los resultados que siguen serán los tuyos. Si no coinciden, algo se alteró al copiar el script: bash -n generar-log.sh es el primer control.

3. Reconocer el formato

Antes de preguntar nada, se mira. Las tres consultas de siempre, del tema 12: cuánto pesa, cómo empieza y cómo termina.

terminal
$ ls -lh acceso.log | awk '{print $5}'
695K
$ head -n 2 acceso.log
203.0.113.47 - - [09/Oct/2026:00:02:29 -0300] "GET /css/estilo.css HTTP/1.1" 200 5715 "https://tienda.example/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/129.0 Safari/537.36"
203.0.113.37 - - [09/Oct/2026:00:10:04 -0300] "GET /css/estilo.css HTTP/1.1" 200 6036 "https://tienda.example/" "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/129.0 Mobile Safari/537.36"

Es el formato que usan por defecto los servidores web más difundidos, nginx y Apache, llamado combinado. Cada línea es un pedido, y tiene siempre las mismas partes en el mismo orden:

anatomía de una línea del registro
203.0.113.47 - - [09/Oct/2026:00:02:29 -0300] "GET /css/estilo.css HTTP/1.1" 200 5715 "https://tienda.example/" "Mozilla/5.0 (Windows…"
     │       │ │            │                   │        │                    │    │              │                       │
     │       │ │            │                   │        │                    │    │              │                       └─ el programa del cliente
     │       │ │            │                   │        │                    │    │              └─ la página desde la que llegó
     │       │ │            │                   │        │                    │    └─ los bytes enviados
     │       │ │            │                   │        │                    └─ el código de estado
     │       │ │            │                   │        └─ la ruta pedida
     │       │ │            │                   └─ el método
     │       │ │            └─ la fecha, la hora y el huso horario
     │       └─┴─ dos campos que casi siempre están vacíos
     └─ la dirección IP del cliente

La pregunta práctica es cómo llegar a cada parte. Con el separador por defecto de awk, los espacios, la línea se divide así:

CampoContieneEn la primera línea
$1La dirección IP del cliente.203.0.113.47
$4La fecha y la hora, con un corchete delante.[09/Oct/2026:00:02:29
$6El método, con una comilla delante: GET pide, POST envía datos."GET
$7La ruta pedida./css/estilo.css
$9El código de estado, de los del tema 40.200
$10Los bytes de la respuesta.5715
terminal
$ head -n 1 acceso.log | awk '{print $1, $4, $6, $7, $9, $10}'
203.0.113.47 [09/Oct/2026:00:02:29 "GET /css/estilo.css 200 5715
$ awk '{print NF}' acceso.log | sort -n | uniq -c
    480 12
     60 13
    153 15
   1003 18
    782 19
    868 20
    856 24

La segunda consulta es una precaución que conviene tomar con todo archivo desconocido: contar cuántos campos tiene cada línea. Aquí hay siete cantidades distintas, de 12 a 24. No es un defecto del archivo: el último dato, el programa del cliente, es un texto con espacios, y cada espacio suma un campo. Los diez primeros campos son estables; del undécimo en adelante, no.

Para los datos que van entre comillas hay otra manera de cortar: usar la comilla como separador. Vista así, la línea tiene siempre siete partes, y las pares son lo que estaba entre comillas.

terminal
$ head -n 1 acceso.log | awk -F'"' '{print $2; print $4; print $6}'
GET /css/estilo.css HTTP/1.1
https://tienda.example/
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/129.0 Safari/537.36
Con -F'"'Contiene
$2El pedido completo: método, ruta y protocolo.
$4El origen: la página en la que estaba el enlace. Un guion, si no se informó.
$6El agente: cómo se identifica el programa del cliente.

Con esas dos maneras de dividir la línea se llega a todos los datos. Es la primera decisión de cualquier análisis, y la que más errores evita: saber con qué separador se obtiene cada cosa.

4. Período y volumen

¿Qué días cubre el archivo? La primera y la última línea lo dicen, y sed las extrae de una vez:

terminal
$ sed -n '1p;$p' acceso.log | awk '{print $4, $5}'
[09/Oct/2026:00:02:29 -0300]
[12/Oct/2026:23:59:30 -0300]
$ awk '{print substr($4, 2, 11)}' acceso.log | uniq -c
   1148 09/Oct/2026
    743 10/Oct/2026
   1163 11/Oct/2026
   1148 12/Oct/2026

Cuatro días completos, del viernes 9 al lunes 12 de octubre. En la segunda consulta, substr($4, 2, 11) toma once caracteres del cuarto campo a partir del segundo, salteando el corchete: la fecha sola. Y uniq -c va sin sort delante. En el tema 26 aprendimos que uniq solo junta líneas iguales contiguas, y que por eso se ordena antes; aquí el archivo ya viene ordenado por fecha, y no ordenar conserva el orden cronológico, que alfabéticamente se perdería.

Ya hay algo para anotar. El sábado tuvo menos movimiento que los días hábiles, como era de esperar. El domingo tuvo más que el viernes. Puede ser cierto, o puede ser otra cosa. Lo dejamos pendiente.

5. Los códigos de estado

El noveno campo dice cómo terminó cada pedido. Es la consulta que da el panorama de salud del servidor, y es el modismo del tema 24, sin agregados:

terminal
$ awk '{print $9}' acceso.log | sort | uniq -c | sort -rn
   3105 200
    480 401
    209 201
    186 404
    102 301
     91 304
     29 500
CódigoSignificaPedidos¿Normal?
200Correcto.3105Sí.
201Correcto, y se creó algo: aquí, un pedido de compra.209Sí. Son las ventas.
301Redirección a otra dirección.102Sí.
304«No cambió»: el navegador ya tenía una copia.91Sí.
401Identificación rechazada.480Son muchísimos.
404La ruta no existe.186Hay que ver cuáles.
500Error del servidor.29Nunca es normal.

Para un informe conviene el porcentaje. Con un arreglo de awk, como los del tema 30, se cuenta y se calcula en una sola pasada; NR, en el bloque END, vale el total de líneas.

terminal
$ awk '{n[$9]++} END {for (e in n) printf "%s %5d %5.1f %%\n", e, n[e], n[e] * 100 / NR}' acceso.log | sort
200  3105  73.9 %
201   209   5.0 %
301   102   2.4 %
304    91   2.2 %
401   480  11.4 %
404   186   4.4 %
500    29   0.7 %

Uno de cada nueve pedidos terminó en un 401. Antes de seguir, una advertencia sobre cómo se buscan estos números, porque es el error que arruina más análisis de registros:

terminal: tres maneras de contar los 404
$ grep -c '404' acceso.log
201
$ grep -c '" 404 ' acceso.log
186
$ awk '$9 == 404' acceso.log | wc -l
186
Buscar un número en toda la línea encuentra de más

La primera consulta cuenta quince líneas que no son errores: tienen la secuencia «404» en otro lado, en la cantidad de bytes o en la hora. grep busca texto, y no sabe de campos. Se lo puede acotar con el contexto, como en la segunda forma, que exige la comilla que cierra el pedido y los espacios alrededor. Pero la manera segura es la tercera: preguntar por el campo. La regla vale para cualquier archivo con columnas: cuando la pregunta es sobre una columna, la herramienta es awk.

Quedan tres números para investigar: los 480, los 186 y los 29. Antes, completamos el panorama.

6. Qué se pide

terminal
$ awk '{print $7}' acceso.log | sort -u | wc -l
29
$ awk '{print $7}' acceso.log | sort | uniq -c | sort -rn | head -n 10
    586 /login
    489 /
    322 /productos
    238 /api/pedidos
    235 /img/logo.png
    233 /css/estilo.css
    232 /carrito
    219 /productos/42
    213 /js/app.js
    126 /img/logo-viejo.png

Veintinueve rutas distintas, y la más pedida no es la página de inicio sino la de ingreso. Segunda anomalía: en una tienda, la gente mira productos mucho más de lo que inicia sesión.

La lista mezcla dos clases de cosas. Cada vez que alguien abre una página, su navegador pide además la hoja de estilos, el programa y las imágenes; esos pedidos inflan el recuento sin ser visitas. Y los productos aparecen separados por número, cuando interesa el total. Tres retoques, uno por herramienta: grep -v quita los archivos auxiliares por su extensión, y sed borra lo que sigue a un signo de pregunta y unifica los productos.

terminal
$ awk '{print $7}' acceso.log |
    grep -vE '\.(css|js|png|ico|pdf|txt)$' |
    sed -E 's/\?.*//; s#^/productos/[0-9]+#/productos/N#' |
    sort | uniq -c | sort -rn | head -n 8
    586 /login
    489 /
    438 /productos/N
    322 /productos
    238 /api/pedidos
    232 /carrito
    223 /buscar
    118 /nosotros
las dos órdenes de sed, leídas
s/\?.*//
  │ │
  │ └─ y todo lo que sigue
  └─ un signo de pregunta, escapado: en -E es un cuantificador
     /buscar?q=mate  →  /buscar

s#^/productos/[0-9]+#/productos/N#
 │           │
 │           └─ uno o más dígitos
 └─ # como delimitador, para no escapar las barras de la ruta
     /productos/42  →  /productos/N

Son los recursos del tema 29: dos órdenes separadas por punto y coma, y otro delimitador cuando el texto lleva barras. Ahora se ve lo que importa para el negocio: las fichas de producto suman 438 visitas, hay 223 búsquedas, y 238 pedidos de compra. La tubería quedó de cuatro renglones, partida como enseñó el tema 41.

7. Quién pide

terminal
$ awk '{print $1}' acceso.log | sort -u | wc -l
42
$ awk '{print $1}' acceso.log | sort | uniq -c | sort -rn | head -n 6
    480 198.51.100.77
    229 198.51.100.10
    188 203.0.113.11
    174 198.51.100.14
    171 203.0.113.19
    167 198.51.100.16

Cuarenta y dos direcciones distintas. Cinco de las seis primeras tienen cantidades parecidas, entre 160 y 230 pedidos en cuatro días: clientes frecuentes. La primera hizo más del doble que la segunda. Y su cantidad es 480, el mismo número de la sección 5.

Una coincidencia así no se deja pasar. Tercera anomalía, y probablemente la misma que la segunda.

8. Cuándo se pide

La hora está dentro del cuarto campo, después de la fecha. Hay un atajo para llegar a ella: los dos puntos. Usándolos como separador, el segundo campo de cada línea es la hora.

terminal
$ awk -F: '{n[$2]++} END {for (h in n) printf "%s %4d %s\n", h, n[h], substr("##################################################", 1, n[h] / 12)}' acceso.log | sort
00   18 #
01   12 #
02    8
03  488 ########################################
04   12 #
05   18 #
06   44 ###
07   96 ########
08  176 ##############
09  256 #####################
10  304 #########################
11  320 ##########################
12  272 ######################
13  224 ##################
14  240 ####################
15  288 ########################
16  304 #########################
17  272 ######################
18  224 ##################
19  192 ################
20  152 ############
21  114 #########
22  130 ##########
23   38 ###

El gráfico se dibuja con una función del tema 30: substr toma, de una tira de cincuenta numerales, tantos como pedidos haya, a razón de uno cada doce. Es un recurso modesto, y alcanza para ver una forma.

La forma es la de cualquier comercio: casi nada de madrugada, un pico a media mañana, una baja al mediodía, otro pico a la tarde. Y una barra que no pertenece a esa curva. A las tres de la madrugada hay más actividad que a las once. ¿De qué día?

terminal
$ awk -F: '$2 == "03"' acceso.log | awk '{print substr($4, 2, 11)}' | uniq -c
      3 09/Oct/2026
      1 10/Oct/2026
    481 11/Oct/2026
      3 12/Oct/2026

El domingo 11, a las tres de la mañana. Las cuatro anomalías —los 401, la ruta /login, la dirección con 480 pedidos, el domingo abultado— apuntan al mismo lugar. Es hora de seguir el hilo.

9. Hallazgo 1: los ingresos rechazados

Cada pregunta es un filtro sobre el campo del estado, seguido de un recuento. ¿De quién son los 401?

terminal
$ awk '$9 == 401 {print $1}' acceso.log | sort | uniq -c
    480 198.51.100.77
$ awk '$1 == "198.51.100.77" {print $6, $7, $9}' acceso.log | sort | uniq -c
    480 "POST /login 401
$ awk '$9 == 401 {print $4}' acceso.log | sed -n '1p;$p'
[11/Oct/2026:03:10:00
[11/Oct/2026:03:49:55
$ awk '$9 == 401 {print substr($4, 14, 5)}' acceso.log | uniq -c | head -n 3
     12 03:10
     12 03:11
     12 03:12
$ grep -m 1 '^198\.51\.100\.77 ' acceso.log | awk -F'"' '{print $6}'
python-requests/2.31.0

Cinco consultas, cinco datos:

PreguntaRespuesta
¿Quién?Una sola dirección, 198.51.100.77, es responsable de los 480.
¿Qué hizo?Exclusivamente enviar datos al formulario de ingreso. Nunca pidió otra cosa: ni la página de inicio, ni una imagen.
¿Cuándo?El domingo 11, de 03:10:00 a 03:49:55. Cuarenta minutos.
¿A qué ritmo?Doce intentos por minuto, exactos, minuto tras minuto. Uno cada cinco segundos.
¿Con qué?No con un navegador: con un programa, que se identifica como una biblioteca de Python.

Ninguna persona escribe una contraseña cada cinco segundos durante cuarenta minutos, a las tres de la mañana, sin mirar una sola página. Es un programa probando contraseñas contra el formulario: lo que se llama un ataque de fuerza bruta. Falta la pregunta que importa: ¿entró?

terminal
$ awk '$1 == "198.51.100.77" && $9 != 401' acceso.log | wc -l
0
$ awk '$7 == "/login" {print $6, $9}' acceso.log | sort | uniq -c
    106 "GET 200
    480 "POST 401

No. De esa dirección no hay ni una respuesta distinta de 401: los 480 intentos fueron rechazados, y después se fue. La segunda consulta da el contexto: en cuatro días, la página de ingreso se mostró 106 veces a visitantes normales, y recibió 480 envíos, todos del atacante.

Quedan explicadas las cuatro anomalías con un solo hecho. Y queda una recomendación para el informe: el servidor aceptó 480 intentos seguidos sin poner ningún obstáculo. Hace falta un límite de intentos por dirección.

10. Hallazgo 2: lo que no existe

Segundo número pendiente: los 186 pedidos de rutas que no existen. ¿Cuáles?

terminal
$ awk '$9 == 404 {print $7}' acceso.log | sort | uniq -c | sort -rn
    126 /img/logo-viejo.png
     13 /phpmyadmin/
     10 /.git/config
      9 /wp-login.php
      9 /.env
      8 /backup.zip
      7 /wp-admin/
      4 /admin

La lista tiene dos mitades que no se parecen en nada: una imagen del propio sitio, y siete rutas que esta tienda nunca tuvo. Un mismo código, dos historias. Las separamos mirando quién las pide.

terminal
$ awk '$9 == 404 {print $1}' acceso.log | sort | uniq -c | sort -rn | head -n 4
     60 192.0.2.146
     12 203.0.113.11
     10 203.0.113.13
      7 198.51.100.14
$ grep '^192\.0\.2\.146 ' acceso.log | awk '{print $9}' | sort | uniq -c
     60 404
$ grep '^192\.0\.2\.146 ' acceso.log | sed -n '1p;$p' | awk '{print $4}'
[10/Oct/2026:22:14:00
[10/Oct/2026:22:28:47
$ grep '^192\.0\.2\.146 ' acceso.log | awk -F'"' '{print $6}' | sort -u
Mozilla/5.0 zgrab/0.x

Una dirección hizo 60 pedidos, todos fallidos, en quince minutos del sábado a la noche, y son exactamente las siete rutas extrañas: 13 más 10 más 9 más 9 más 8 más 7 más 4. Su agente nombra una herramienta de exploración. Es un escáner: un programa que recorre Internet probando, en cada servidor que encuentra, las direcciones donde suelen quedar olvidados archivos de configuración, copias de respaldo y paneles de administración. No atacaba a esta tienda en particular; pasa por todos los servidores del mundo, varias veces por día. Que todo le haya dado 404 es la buena noticia: no había nada de eso para encontrar.

En los patrones de grep, los puntos de la dirección van escapados y hay un ancla al principio. Sin eso, 192.0.2.146 coincidiría también con 192.0.2.1460, o con ese texto en medio de una línea: lo que aprendimos en el tema 28 sobre el punto.

La otra mitad son los 126 pedidos de una imagen. Esos no vienen de un programa de exploración:

terminal
$ awk '$7 == "/img/logo-viejo.png" {print $1}' acceso.log | sort -u | wc -l
35
$ awk -F'"' '$2 ~ /logo-viejo/ {print $4}' acceso.log | sort | uniq -c
    126 https://tienda.example/nosotros

La pidieron 35 direcciones distintas, casi todos los clientes del sitio, y en los 126 casos el origen —el cuarto campo, cortando por comillas— es la misma página del sitio: /nosotros. Esa página tiene una referencia a una imagen que alguien borró o renombró. Cada visitante que la abre ve un hueco donde debería estar el logotipo. No es un problema de seguridad sino un defecto del sitio, y el registro dice con precisión dónde está: se corrige editando una línea de una página.

El campo del origen es de los menos usados y de los más útiles: ante un enlace roto, responde la pregunta que de otro modo llevaría horas, «¿desde dónde se está llegando a esto?».

11. Hallazgo 3: la mañana del lunes

Tercer número, y el del encargo original: 29 errores del servidor. A diferencia de los anteriores, un 500 no es culpa de quien pide. El pedido era correcto, y el servidor no pudo atenderlo.

terminal
$ awk '$9 == 500 {print $6, $7}' acceso.log | sort | uniq -c
     29 "POST /api/pedidos
$ awk '$9 == 500 {print substr($4, 2, 14)}' acceso.log | uniq -c
     29 12/Oct/2026:10
$ awk '$9 == 500 {print $4}' acceso.log | sed -n '1p;$p'
[12/Oct/2026:10:05:33
[12/Oct/2026:10:38:23
$ awk '$9 == 500 {print substr($4, 14, 4) "0"}' acceso.log | uniq -c
      5 10:00
      9 10:10
      8 10:20
      7 10:30

Los 29 son del mismo tipo —el envío de un pedido de compra—, y todos del lunes 12 entre las 10:05 y las 10:38. Ni uno antes, ni uno después, en cuatro días. La última consulta agrupa por tramos de diez minutos, tomando la hora y la decena del minuto: el problema no fue un instante, sino que se mantuvo parejo durante más de media hora.

¿Fallaron todas las compras en ese rato, o algunas?

terminal
$ grep '12/Oct/2026:10:' acceso.log | awk '$7 == "/api/pedidos" {print $9}' | sort | uniq -c
      4 201
     29 500
$ grep '12/Oct/2026:10:' acceso.log | awk '$7 == "/api/pedidos" && $9 == 201 {print substr($4, 14, 8)}'
10:42:57
10:50:58
10:56:58
10:57:29
$ awk '$9 == 500 {print $1}' acceso.log | sort -u | wc -l
18

Todas. En esa hora hubo 33 intentos de compra: 29 fallaron, y los cuatro que funcionaron son posteriores a las 10:42. El servicio de pedidos estuvo completamente caído durante 33 minutos y volvió solo, o alguien lo reinició. El resto del sitio siguió funcionando, y por eso nadie lo notó desde adentro: se podía navegar, buscar y llenar el carrito. Lo único que no se podía hacer era comprar.

Los afectados fueron 18 clientes distintos. Ese es el número que pidió la dueña. El registro del servidor web no dice por qué se cayó; para eso hay que ir, con el horario exacto en la mano, al registro de la aplicación, con el journalctl --since "10:00" --until "10:45" del tema 39. Pero ya se sabe qué buscar, y cuándo.

12. Robots y visitas reales

Para responder la segunda parte del encargo —cómo anduvo el fin de semana— hay que saber qué parte del tráfico son personas. El agente lo dice:

terminal
$ awk -F'"' '{print $6}' acceso.log | sort | uniq -c | sort -rn
   1003 Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0
    868 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/129.0 Safari/537.36
    856 Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile Safari/604.1
    782 Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/129.0 Mobile Safari/537.36
    480 python-requests/2.31.0
    153 Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)
     60 Mozilla/5.0 zgrab/0.x

Siete agentes. Las cadenas son largas y engañosas —todas dicen «Mozilla», y la de Chrome dice además «Safari»—, por razones históricas que no vienen al caso. Para un informe se las reduce a un nombre, con una orden de sed por familia. El orden de las órdenes importa: primero los robots, luego lo más específico, y Chrome al final, porque su nombre aparece también en las otras.

terminal
$ awk -F'"' '{print $6}' acceso.log |
    sed -E 's/.*(Googlebot|zgrab|python-requests).*/\1/
            s/.*Firefox.*/Firefox/
            s/.*iPhone.*/Safari (iPhone)/
            s/.*Android.*/Chrome (Android)/
            s/.*Chrome.*/Chrome/' |
    sort | uniq -c | sort -rn
   1650 Chrome
   1003 Firefox
    856 Safari (iPhone)
    480 python-requests
    153 Googlebot
     60 zgrab

La primera orden usa un grupo y su retrorreferencia, \1: reemplaza la línea entera por el nombre del robot que encontró. Hay un detalle que el resultado delata: la cuarta orden convierte las líneas de Android en Chrome (Android), y la quinta, que busca «Chrome», las vuelve a transformar, porque el texto nuevo también lo contiene. Por eso Chrome suma 1650, que son los 868 de escritorio más los 782 de teléfono. Para este informe sirve, pero es la clase de efecto que hay que verificar con una suma, y que muestra por qué en sed las órdenes se piensan en orden.

Tres de los agentes no son personas: el atacante, el escáner y Googlebot, el programa con que el buscador recorre los sitios para indexarlos, que es un visitante bienvenido. Entre los tres suman 693 líneas. Las apartamos:

terminal
$ grep -vE 'Googlebot|zgrab|python-requests' acceso.log > visitas.log
$ wc -l acceso.log visitas.log
   4202 acceso.log
   3509 visitas.log
   7711 total
$ awk '{print substr($4, 2, 11)}' visitas.log | uniq -c
   1103 09/Oct/2026
    657 10/Oct/2026
    659 11/Oct/2026
   1090 12/Oct/2026
$ awk '{print $1}' visitas.log | sort -u | wc -l
39

Y aquí se resuelve lo que dejamos pendiente en la sección 4. El domingo no tuvo más movimiento que el viernes: tuvo 659 pedidos de personas, casi los mismos que el sábado. Lo que lo inflaba era el ataque. Con los datos limpios, la respuesta a «cómo anduvo el fin de semana» es otra, y es la correcta: sábado y domingo, un 40 % menos que un día hábil; el lunes, igual que el viernes.

Un promedio sobre datos sin limpiar describe a los robots

Si hubiéramos informado los números de la sección 4, la dueña habría leído que el domingo fue el mejor día de la semana, y quizá decidido algo con eso. En un sitio pequeño es habitual que la mitad de los pedidos no venga de personas. Toda medición de visitas empieza por decidir qué se cuenta. Y conviene conservar los dos archivos: acceso.log para las preguntas de seguridad, donde los robots son justamente lo que interesa, y visitas.log para las de negocio.

Una última consulta, con dos herramientas que no habíamos usado en el proyecto. ¿Hay clientes que solo produjeron errores, sin un solo pedido correcto? Es una resta de conjuntos: los que tuvieron algún error, menos los que tuvieron algún acierto. La hace comm, del tema 31, sobre dos listas que se arman al vuelo con la sustitución de procesos del tema 25:

terminal
$ comm -23 <(awk '$9 >= 400 {print $1}' acceso.log | sort -u) <(awk '$9 < 400 {print $1}' acceso.log | sort -u)
192.0.2.146
198.51.100.77

Dos, y ya los conocemos. Es una consulta que vale la pena recordar: sin saber nada de antemano, señala a los que vinieron a hacer otra cosa que comprar.

13. Cuánto se transfirió

El décimo campo permite sumar, y la suma es el consumo de ancho de banda, que en muchos servicios de alojamiento se paga.

terminal
$ awk '{t += $10} END {printf "%d bytes = %.1f MiB\n", t, t / 1024 / 1024}' acceso.log
236468917 bytes = 225.5 MiB
$ awk '{b[$7] += $10} END {for (r in b) printf "%8.1f MiB  %s\n", b[r] / 1024 / 1024, r}' acceso.log | sort -rn | head -n 3
   213.9 MiB  /descargas/catalogo.pdf
     1.3 MiB  /productos
     1.1 MiB  /productos/42
$ awk '$7 ~ /catalogo/' acceso.log | wc -l
93

Más de doscientos mebibytes en cuatro días, y un solo archivo explica el 95 %. El catálogo en PDF se descargó 93 veces, el 2 % de los pedidos, y pesa 2,3 MiB cada vez. Todo lo demás junto, las cuatro mil líneas restantes, no llega a doce.

Es un resultado típico, y enseña algo sobre qué medir. Contando pedidos, el catálogo ni siquiera figuraba entre las diez rutas de la sección 6. Sumando bytes, es prácticamente todo el tráfico. La misma tabla, ordenada por otra columna, cuenta otra historia. La recomendación es obvia: comprimir ese PDF.

14. Del registro a una tabla, con sed

Hasta aquí cada consulta volvió a extraer los datos del formato original, con sus corchetes, sus comillas y sus dos separadores. Cuando se van a hacer muchas preguntas conviene hacer el trabajo una vez: convertir el registro en una tabla limpia, con los campos que interesan separados por comas. Es un trabajo para sed, y la expresión se construye de a poco, probando sobre una línea, como enseñó el tema 28.

terminal: la expresión, en tres pasos
$ head -n 1 acceso.log | sed -E 's/^([^ ]+) .*/\1/'
203.0.113.47
$ head -n 1 acceso.log | sed -E 's/^([^ ]+) - - \[([^:]+):([^ ]+) .*/\1,\2,\3/'
203.0.113.47,09/Oct/2026,00:02:29
$ head -n 1 acceso.log | sed -E 's/^([^ ]+) - - \[([^:]+):([^ ]+) [^]]+\] "([A-Z]+) ([^ ]+) [^"]+" ([0-9]+) ([0-9]+) .*/\1,\2,\3,\4,\5,\6,\7/'
203.0.113.47,09/Oct/2026,00:02:29,GET,/css/estilo.css,200,5715

La idea es describir la línea entera, de punta a punta, encerrando entre paréntesis lo que se quiere conservar, y reemplazarla por los grupos unidos con comas. Pieza por pieza:

PiezaCoincide conGrupo
^([^ ]+)Desde el principio, todo lo que no sea un espacio: la dirección.\1
- - \[Los dos guiones y el corchete que abre, escapado.—
([^:]+)Hasta los primeros dos puntos: la fecha.\2
:([^ ]+)Después de los dos puntos, hasta el espacio: la hora.\3
[^]]+\] "El huso horario, el corchete que cierra y la comilla que abre.—
([A-Z]+)Letras mayúsculas: el método.\4
([^ ]+)La ruta.\5
[^"]+" El protocolo, hasta la comilla que cierra.—
([0-9]+) ([0-9]+)El estado y los bytes.\6, \7
.*Todo el resto, que se descarta.—

El recurso que se repite es la clase negada, [^ ]+: «todo lo que no sea un espacio». Es más preciso que .*, que por la codicia del tema 28 se llevaría la línea entera. Ahora, sobre todo el archivo:

terminal
$ sed -E 's/^([^ ]+) - - \[([^:]+):([^ ]+) [^]]+\] "([A-Z]+) ([^ ]+) [^"]+" ([0-9]+) ([0-9]+) .*/\1,\2,\3,\4,\5,\6,\7/' acceso.log > acceso.csv
$ head -n 3 acceso.csv
203.0.113.47,09/Oct/2026,00:02:29,GET,/css/estilo.css,200,5715
203.0.113.37,09/Oct/2026,00:10:04,GET,/css/estilo.css,200,6036
66.249.66.1,09/Oct/2026,00:27:31,GET,/robots.txt,200,68
$ wc -l acceso.csv
4202 acceso.csv
$ grep -vc '^[0-9.]*,[0-9]*/Oct/2026,' acceso.csv
0

Las dos últimas líneas son el control de calidad, que no se omite nunca después de una transformación. Cuando la expresión de sed no coincide con una línea, sed no avisa: la deja pasar sin cambios. Por eso se cuenta que estén las 4202, y se cuenta cuántas no tienen la forma nueva. Cero: se convirtieron todas. El archivo pasó de 695 K a 239 K, y tiene siete columnas con nombre fácil:

1234567
direcciónfechahoramétodorutaestadobytes

Sobre la tabla, las consultas se vuelven breves, y sirven hasta los filtros más simples del tema 26. Los errores, por día y por tipo, con cut:

terminal
$ cut -d, -f2,6 acceso.csv | sort | uniq -c | grep ',[45]'
     27 09/Oct/2026,404
     90 10/Oct/2026,404
    480 11/Oct/2026,401
     28 11/Oct/2026,404
     41 12/Oct/2026,404
     29 12/Oct/2026,500
$ awk -F, '{n[$2]++; b[$2] += $7} END {for (d in n) printf "%s %5d pedidos %6.1f MiB\n", d, n[d], b[d] / 1048576}' acceso.csv | sort
09/Oct/2026  1148 pedidos   65.8 MiB
10/Oct/2026   743 pedidos   43.6 MiB
11/Oct/2026  1163 pedidos   52.7 MiB
12/Oct/2026  1148 pedidos   63.4 MiB

La primera es el resumen de los tres hallazgos en seis líneas: el escáner del sábado, el ataque del domingo, la caída del lunes. Y con la tabla se puede responder una pregunta que con el formato original habría sido engorrosa, y que le interesa a la dueña más que ninguna: de los 18 clientes a los que les falló la compra, ¿cuántos volvieron a intentarlo y pudieron?

terminal
$ awk -F, '$6 == 500 {print $1}' acceso.csv | sort -u > fallaron.txt
$ awk -F, '$2 == "12/Oct/2026" && $3 > "10:38:23" && $5 == "/api/pedidos" && $6 == 201 {print $1}' acceso.csv | sort -u > compraron.txt
$ wc -l fallaron.txt compraron.txt
 18 fallaron.txt
 19 compraron.txt
 37 total
$ comm -12 fallaron.txt compraron.txt | wc -l              # en las dos listas
11
$ comm -23 fallaron.txt compraron.txt                      # solo en la primera
198.51.100.24
198.51.100.30
198.51.100.38
203.0.113.11
203.0.113.27
203.0.113.31
203.0.113.33

Dos listas ordenadas y comm. La primera, los que recibieron un error; la segunda, los que completaron una compra ese mismo lunes, después de la última falla. La comparación de horas, $3 > "10:38:23", funciona como comparación de textos porque el formato HH:MM:SS se ordena igual alfabética que cronológicamente. De los 18 afectados, 11 volvieron y compraron. Siete no, y ahí están sus direcciones. Son siete ventas que quizá se puedan recuperar, si la aplicación sabe a qué clientes corresponden.

Una nota para cuando un registro deba compartirse fuera de la empresa: las direcciones IP son datos personales. Otra orden de sed las recorta, dejando solo la red: sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+)\.[0-9]+/\1.0/' acceso.log.

15. El informe, en un script

El análisis está hecho, y si se queda en el historial se pierde. El lunes que viene habrá otro registro y las mismas preguntas. Es el caso exacto del tema 41: las consultas que resultaron útiles se guardan en un archivo, con el registro como argumento.

~/practica/proyecto/informe-log.sh
#!/bin/bash
# informe-log.sh: resume un registro de servidor web en formato combinado.
# Uso: ./informe-log.sh [archivo]        (por defecto, acceso.log)

set -eu

log="${1:-acceso.log}"
robots='Googlebot|zgrab|python-requests'

# Si el archivo no se puede leer, el script termina aquí
head -n 1 "$log" > /dev/null

echo "INFORME DE $log"
echo
echo "== Período"
echo "Desde:    $(head -n 1 "$log" | awk '{print substr($4, 2)}')"
echo "Hasta:    $(tail -n 1 "$log" | awk '{print substr($4, 2)}')"
echo "Pedidos:  $(wc -l < "$log")"
echo "Clientes: $(awk '{print $1}' "$log" | sort -u | wc -l)"
echo
echo "== Pedidos por día"
awk '{print substr($4, 2, 11)}' "$log" | uniq -c
echo
echo "== Códigos de estado"
awk '{n[$9]++} END {for (e in n) printf "%s %6d %5.1f %%\n", e, n[e], n[e] * 100 / NR}' "$log" | sort
echo
echo "== Las 5 rutas más pedidas, sin robots"
grep -vE "$robots" "$log" | awk '{print $7}' | sort | uniq -c | sort -rn | head -n 5
echo
echo "== Los 5 clientes más activos"
awk '{print $1}' "$log" | sort | uniq -c | sort -rn | head -n 5
echo
echo "== Errores del servidor (5xx), por hora"
awk '$9 >= 500 {print substr($4, 2, 14)}' "$log" | uniq -c
echo
echo "== Rutas inexistentes más pedidas (404)"
awk '$9 == 404 {print $7}' "$log" | sort | uniq -c | sort -rn | head -n 5
echo
echo "== Ingresos rechazados (401), por cliente"
awk '$9 == 401 {print $1}' "$log" | sort | uniq -c | sort -rn | head -n 5
echo
echo "== Transferencia"
awk '{t += $10} END {printf "%.1f MiB en %d pedidos\n", t / 1048576, NR}' "$log"

Tiene todas las piezas del tema anterior y ninguna otra: la primera línea, el encabezado, set -eu, un argumento con valor por defecto, los datos en variables arriba, y cada variable entre comillas. Tres detalles nuevos, todos pequeños:

LíneaPor qué está escrita así
head -n 1 "$log" > /dev/nullNo muestra nada. Está para que, si el archivo no existe o no se puede leer, head falle y set -e detenga el script ahí, con un mensaje claro, en lugar de imprimir un informe lleno de errores.
wc -l < "$log"Con la redirección, wc no conoce el nombre del archivo y escribe solo el número. Con el nombre como argumento escribiría 4202 acceso.log.
grep -vE "$robots"La lista de robots está en una variable, arriba: cuando aparezca uno nuevo se agrega en un solo lugar. Va entre comillas dobles, para que la variable se sustituya.
terminal
$ chmod +x informe-log.sh
$ ./informe-log.sh
INFORME DE acceso.log

== Período
Desde:    09/Oct/2026:00:02:29
Hasta:    12/Oct/2026:23:59:30
Pedidos:  4202
Clientes: 42

== Pedidos por día
   1148 09/Oct/2026
    743 10/Oct/2026
   1163 11/Oct/2026
   1148 12/Oct/2026

== Códigos de estado
200   3105  73.9 %
201    209   5.0 %
301    102   2.4 %
304     91   2.2 %
401    480  11.4 %
404    186   4.4 %
500     29   0.7 %

== Las 5 rutas más pedidas, sin robots
    480 /
    296 /productos
    238 /api/pedidos
    232 /carrito
    229 /img/logo.png

== Los 5 clientes más activos
    480 198.51.100.77
    229 198.51.100.10
    188 203.0.113.11
    174 198.51.100.14
    171 203.0.113.19

== Errores del servidor (5xx), por hora
     29 12/Oct/2026:10

== Rutas inexistentes más pedidas (404)
    126 /img/logo-viejo.png
     13 /phpmyadmin/
     10 /.git/config
      9 /wp-login.php
      9 /.env

== Ingresos rechazados (401), por cliente
    480 198.51.100.77

== Transferencia
225.5 MiB en 4202 pedidos

Una pantalla. Quien la lee sabiendo lo que nosotros sabemos ve los tres hallazgos de un vistazo: una dirección que encabeza la lista de clientes y es la única con ingresos rechazados; errores del servidor concentrados en una hora; una imagen que falta, y un escáner. Lo que nos llevó diez secciones descubrir, el script lo muestra en un segundo, sobre cualquier registro del mismo formato.

terminal
$ ./informe-log.sh /noexiste; echo "código: $?"
head: cannot open '/noexiste' for reading: No such file or directory
código: 1
$ ./informe-log.sh visitas.log | sed -n '3,7p'
== Período
Desde:    09/Oct/2026:00:02:29
Hasta:    12/Oct/2026:23:59:30
Pedidos:  3509
Clientes: 39
$ ./informe-log.sh > informe.txt
$ cp informe-log.sh ~/bin/informe-log

Se comporta como un comando: falla con un código distinto de cero cuando no puede trabajar, acepta otro archivo, su salida se redirige y se filtra. Instalado en ~/bin, queda disponible desde cualquier directorio. Y todo lo del tema 41 sobre dónde ejecutar un script se aplica aquí con provecho:

Para…Se escribe
Analizar el registro actual de un servidor nginxsudo informe-log /var/log/nginx/access.log
Analizar el de la semana pasada, ya rotado y comprimidozcat /var/log/nginx/access.log.2.gz > /tmp/semana.log; informe-log /tmp/semana.log
Analizar el de un servidor remoto, sin copiar nadassh servidor 'bash -s' < ~/bin/informe-log
Recibir el informe todos los lunes a las 8Una línea de crontab: 0 8 * * 1 /home/ana/bin/informe-log … >> …
Ver los errores del servidor en el momento en que ocurrentail -f /var/log/nginx/access.log | awk '$9 >= 500'

La última fila no usa el script, pero es la continuación natural: los mismos filtros, aplicados a un archivo que crece, convierten el análisis de ayer en la vigilancia de hoy.

16. Las conclusiones, y lo que se usó

El trabajo no termina en los números. La dueña de la tienda no pidió tablas: pidió saber qué pasó. El último paso es escribirlo en su idioma, y cada frase de esta respuesta está respaldada por una consulta que se puede repetir.

La respuesta

Qué pasó el lunes. Entre las 10:05 y las 10:38 no se pudo completar ninguna compra: el servicio de pedidos falló en los 29 intentos de ese rato. El resto del sitio funcionaba, por eso no se notó. A las 10:42 ya andaba de nuevo. Afectó a 18 clientes; 11 volvieron y compraron ese mismo día, y 7 no. Tengo sus direcciones y el horario exacto para que el programador busque la causa.

El fin de semana. Sábado y domingo hubo unas 650 visitas por día, un 40 % menos que un día hábil, que ronda las 1100. En cuatro días se concretaron 209 compras, de 33 clientes distintos.

Tres cosas que no preguntaste. El domingo a las 3 de la mañana, un programa probó 480 contraseñas en el formulario de ingreso, durante 40 minutos. No entró, pero el sitio no le puso ningún límite: hay que agregar uno. La página «Nosotros» tiene una imagen rota, que vieron casi todos los clientes. Y el catálogo en PDF es el 95 % de todo lo que transmite el servidor: conviene achicarlo.

Todo salió de un archivo de texto y de comandos que ya conocías. Vale la pena mirar la lista completa, porque es, en pequeño, el inventario del curso:

Se usóParaTema
wc, head, tail, ls -lhReconocer el archivo.7, 12
|Unir cada herramienta con la siguiente.24
sort | uniq -c | sort -rnResponder «¿cuál es el más frecuente?»: estados, rutas, clientes, agentes.24, 26
uniq -c, sin ordenarContar por día y por hora, conservando el orden del tiempo.26
cutElegir columnas de la tabla.26
grep, -v, -c, -E, -mElegir líneas: un cliente, una fecha, todo menos los robots.27
Anclas, clases, clases negadas, gruposDescribir con exactitud una dirección, una extensión, una línea entera.28
sed -E 's/…/…/', sed -n '1p;$p'Unificar rutas, abreviar agentes, convertir el registro en una tabla.29
awk: campos, -F, patrones, substr, arreglos, printfFiltrar por columna, extraer la fecha, contar, sumar y calcular porcentajes.30
commRestar y cruzar dos listas de clientes.31
>, <, <( ), $( )Guardar resultados, y usar la salida de un comando como archivo o como texto.23, 25
sha256sumVerificar que el archivo es el esperado.40
Un script con argumentos y set -euDejar el análisis listo para repetirlo.41

Ninguna de estas herramientas sabe qué es un servidor web. sort ordena líneas, uniq cuenta repetidas, awk separa campos. El mismo procedimiento sirve para un registro de correo, las ventas de un año en CSV, la salida de journalctl o el historial de la shell. Lo que se aprendió no es a analizar registros: es a interrogar texto.

17. Resumen

18. Preguntas y desafíos

Las preguntas repasan las decisiones del proyecto. Intenta responder cada una antes de abrirla.

1. ¿Por qué conviene contar los campos de cada línea, con awk '{print NF}', antes de empezar?

Para saber si todas las líneas tienen la misma estructura. Si la cantidad varía, hay campos con espacios adentro, y no se puede contar con la posición de los que vienen después.

2. ¿Por qué el agente se obtiene con awk -F'"' '{print $6}' y no con el separador por defecto?

Porque es un texto con espacios, que con el separador por defecto queda repartido en una cantidad variable de campos. Cortando por comillas es siempre el sexto.

3. En awk '{print substr($4, 2, 11)}' acceso.log | uniq -c no hay sort. ¿Por qué funciona, y qué se ganó?

Funciona porque el registro ya está ordenado por fecha, y las líneas iguales quedan contiguas. No ordenar conserva el orden cronológico de los días.

4. grep -c '404' acceso.log da 201 y awk '$9 == 404' acceso.log | wc -l da 186. ¿Cuál es correcto, y por qué difieren?

El segundo. grep cuenta toda línea que contenga esa secuencia de caracteres, también en los bytes o en la hora. awk pregunta por el campo del estado.

5. ¿Por qué el patrón para buscar una dirección se escribe '^192\.0\.2\.146 '?

El ancla exige que esté al principio de la línea, los puntos escapados son puntos y no «cualquier carácter», y el espacio final impide que coincida con una dirección más larga.

6. ¿Qué datos permitieron afirmar que los 401 eran un ataque y no usuarios que olvidaron su contraseña?

Venían todos de una sola dirección, que no pidió ninguna otra cosa; a un ritmo fijo de doce por minuto durante cuarenta minutos; de madrugada; y desde un programa, no un navegador.

7. ¿Cómo se distinguió el enlace roto del escáner, si los dos producían el código 404?

Por quién pedía y desde dónde: el escáner era una sola dirección, sin origen, pidiendo rutas ajenas al sitio; la imagen la pedían 35 direcciones, todas desde la misma página del sitio.

8. ¿Por qué el recuento de pedidos por día cambió al quitar los robots, y cuál de los dos se informa?

Porque los 480 pedidos del ataque estaban contados en el domingo. Para describir la actividad de los clientes se informa el recuento sin robots.

9. Después de convertir el registro con sed, ¿por qué se verifica con wc -l y con grep -vc?

Porque sed deja pasar sin cambios las líneas que no coinciden con la expresión, y no avisa. Hay que comprobar que estén todas y que todas tengan la forma nueva.

10. ¿Qué hace comm -23 fallaron.txt compraron.txt, y qué requisito tienen los dos archivos?

Muestra las líneas que están en el primero y no en el segundo: los clientes que tuvieron un error y no compraron después. Los dos archivos deben estar ordenados.

11. En el script, ¿para qué sirve la línea head -n 1 "$log" > /dev/null?

Para comprobar que el archivo se puede leer. Si no, head falla, y por set -e el script termina en ese punto con el mensaje de error, antes de escribir nada.

12. ¿Por qué el catálogo no figuraba entre las diez rutas más pedidas y era primero por bytes?

Porque se pide pocas veces pero es muy grande. Cantidad de pedidos y volumen transferido son medidas distintas, y cada una ordena la tabla de otro modo.

Desafíos

Cada uno se resuelve con una línea, sobre acceso.log o acceso.csv. La respuesta está oculta: compara tu número antes de mirar el comando.

1. ¿Cuántos pedidos usaron el método POST?

718. cut -d, -f4 acceso.csv | sort | uniq -c, o awk '$6 == "\"POST"' acceso.log | wc -l.

2. ¿Qué se buscó más en el sitio, «mate» o «termo»?

«mate», 120 veces contra 103. awk '$7 ~ /^\/buscar/ {print $7}' acceso.log | sed 's/.*q=//' | sort | uniq -c.

3. ¿Cuántos clientes distintos concretaron al menos una compra?

33. awk -F, '$5 == "/api/pedidos" && $6 == 201 {print $1}' acceso.csv | sort -u | wc -l.

4. ¿Cuántos pedidos hizo Googlebot, y cuál fue la ruta que más pidió?

153, y /robots.txt, 57 veces. grep Googlebot acceso.log | awk '{print $7}' | sort | uniq -c | sort -rn | head -n 1.

5. Sin contar los robots, ¿cuál fue la hora de más actividad, sumando los cuatro días?

Las 11, con 307 pedidos. awk -F: '{print $2}' visitas.log | sort | uniq -c | sort -rn | head -n 1.

6. ¿Qué día hubo más errores 404?

El sábado 10, con 90: el día del escáner. awk -F, '$6 == 404 {print $2}' acceso.csv | uniq -c.

7. ¿Qué archivo recibió más respuestas 304?

/js/app.js, con 32. awk '$9 == 304 {print $7}' acceso.log | sort | uniq -c | sort -rn.

8. ¿Qué ruta produce todas las redirecciones 301?

/blog/, con la barra final: 102 veces. awk '$9 == 301 {print $7}' acceso.log | sort | uniq -c.

9. ¿Cuántos megabytes se transfirieron el viernes 9?

65.8 MiB. awk -F, '$2 == "09/Oct/2026" {t += $7} END {printf "%.1f\n", t / 1048576}' acceso.csv.

10. ¿Qué cliente hizo más compras, y cuántas?

198.51.100.12, con 17. awk -F, '$5 == "/api/pedidos" && $6 == 201 {print $1}' acceso.csv | sort | uniq -c | sort -rn | head -n 1.

Para seguir

  1. Agrega al script una sección con las búsquedas más frecuentes, y otra con el reparto por navegador, usando la orden de sed de la sección 12. Corrige en ella el doble reemplazo de Chrome, para que los teléfonos se cuenten aparte.
  2. Agrega una sección «Sospechosos», con las direcciones que solo produjeron errores, usando comm.
  3. Haz que el script acepte un segundo argumento optativo: la cantidad de filas de cada lista, con 5 por defecto.
  4. Modifica generar-log.sh: cambia el valor inicial de x, genera otro registro y analízalo con tu script. ¿Qué hallazgos se mantienen, y cuáles cambian de magnitud?
  5. Aplica el mismo método a un registro real de tu sistema. Con journalctl -b --no-pager -o short-iso como entrada, averigua qué programas escriben más mensajes, a qué hora, y cuántos son errores. Con /var/log/auth.log, si existe, cuántas veces se usó sudo y para qué comandos.
  6. Escribe para un compañero, en media página, las conclusiones de ese análisis: qué encontraste, con qué número lo respaldas y qué recomendarías.

Con este proyecto se usó, en un solo trabajo, casi todo lo que el curso enseñó. Queda una página: mirar el camino completo, poner nombre a lo que ahora sabes hacer, y ver hacia dónde sigue. Es el tema 43.