como o sentinel-forge acha força bruta num auth.log de verdade
do log do sshd até uma detecção que se explica - parser, evento normalizado, regra AUTH-001 e janela deslizante por IP
7 minver no github
tl;dr#
o sentinel-forge lê um auth.log do sshd, transforma cada linha relevante num evento normalizado, passa os eventos pela regra AUTH-001 (10 falhas em 60 s, por IP de origem) e imprime uma detecção que explica de onde veio. no caminho tem uma armadilha clássica: o sshd loga a mesma tentativa em duas linhas, e contar as duas inflaria o alerta. por isso a linha Invalid user virou um tipo de evento próprio, o invalid_user.
o problema#
log de sshd é texto solto. uma tentativa de login falha parece com isso:
Oct 3 14:32:14 bastion01 sshd[1303]: Failed password for root from 203.0.113.45 port 51240 ssh2
pra detectar força bruta eu preciso de três coisas que o texto não entrega de bandeja: o que aconteceu (falha de autenticação), de onde (IP de origem) e quando (e o log clássico nem tem ano). e a regra não deveria saber nada de formato de log: se amanhã eu ler nginx ou outro log, a mesma regra tem que continuar valendo.
o caminho completo#
linha do sshd → parser → evento normalizado → regra AUTH-001 → janela por IP → detecção
1. o parser: linha vira evento#
o parser do sshd (internal/parser/sshd.go) casa cada mensagem com um regex ancorado. a de falha, por exemplo:
sshdFailed = regexp.MustCompile(`^Failed (\S+) for (invalid user )?(.+) from (\S+) port ([0-9]{1,5})(?: ssh2)?$`)
o nome de usuário pode ter espaço (é controlado por quem ataca), então ele é casado de forma gulosa: o último from <ip> port <n> da linha é o que o sshd acrescentou. o IP passa por netip pra validar e normalizar, então um host é sempre um grupo só.
o resultado é um evento com campos fixos, sem nada de texto de log:
event.Event{
ID: "sshd-<n da linha>",
Timestamp: ts,
Source: "sshd",
Type: "authentication_failure",
Actor: &event.Actor{Username: user},
Network: &event.Network{SourceIP: ip},
Target: &event.Target{Host: host},
Metadata: md, // method, port, reason
}
duas decisões que importam:
- o ID vem da linha.
sshd-14,sshd-15... ler o mesmo arquivo duas vezes dá os mesmos eventos. - o ano é parâmetro. o syslog clássico (
Oct 3 14:32:11) não tem ano, então o replay recebe--year 2026. sem isso o resultado mudaria de um ano pro outro.
o que não é evento de interesse (Server listening, pam_unix(...), Connection closed) é contado como linha pulada. nunca é erro.
2. a regra#
essa é a AUTH-001, em rules/authentication/AUTH-001.yml, do jeito que está no repo:
id: AUTH-001
version: 1
name: Brute Force Authentication
description: >
Detects repeated authentication failures
from the same source IP.
severity: high
tags:
- authentication
- brute-force
when:
type: authentication_failure
threshold:
count: 10
window: 60s
group_by:
- network.sourceIp
attack:
tactic: credential-access
technique: T1110
references:
- https://attack.mitre.org/techniques/T1110/
lendo de cima pra baixo: 10 eventos authentication_failure em 60 s, contados separadamente por IP de origem. é a técnica T1110 do MITRE ATT&CK. a regra só fala de type e de network.sourceIp, que são campos do evento normalizado. ela não sabe que existe sshd.
3. o motor: janela deslizante no tempo do evento#
pra cada evento que bate com o when, o motor monta uma chave (regra + IP) e guarda os horários dos acertos daquela chave. a cada evento novo, ele descarta o que saiu da janela e checa se bateu o limiar:
hits := append(prune(e.windows[key], ev.Timestamp.Add(-window)), hit{ev.ID, ev.Timestamp})
if len(hits) < r.Threshold.Count {
e.windows[key] = hits
continue
}
o ponto central: a janela usa ev.Timestamp, o horário que está no log, e nunca o relógio da máquina. rodar o mesmo log de novo dá sempre a mesma detecção, hoje ou daqui a um ano. por isso o replay ordena os eventos por timestamp antes de avaliar.
quando o limiar é atingido, a detecção abre. se os acertos continuam chegando dentro da janela, eles estendem a mesma detecção em vez de abrir outra. um ataque vira um alerta, não um alerta por tentativa.
a saída: detecção que se explica#
esse é o resultado real de go run ./cmd/sentinelforge replay --format sshd --year 2026 fixtures/authentication/auth.log, rodado no branch feat/log-parsers:
SentinelForge Detection Engine
✓ 25 events processed
· 10 lines skipped (not security events)
✓ 2 rules evaluated
────────────────────────────────────────
🚨 Detection triggered
AUTH-001 v1
Brute Force Authentication
Severity: HIGH
Group: network.sourceIp=203.0.113.45
Matched: 14 events
Window: 42s (14:32:12 → 14:32:54)
Threshold: 10 events / 60s
MITRE ATT&CK: T1110 (credential-access)
Reason:
14 events matched type=authentication_failure, reaching the threshold of 10 within 60s.
────────────────────────────────────────
1 detections in 8ms
exit 0
o arquivo de teste tem 35 linhas: gente normal entrando, a alice errando a senha duas vezes e acertando, o bob com uma falha, e um atacante em 203.0.113.45 tentando admin, root, test, oracle, ubuntu e postgres. os IPs são de documentação (192.0.2.0/24, 198.51.100.0/24 e 203.0.113.0/24), não de ninguém.
e o que a saída mostra: só o IP do atacante disparou. a alice e o bob têm poucas falhas, cada um num grupo separado, então nem chegam perto de 10. cada campo da detecção responde uma pergunta: qual regra e versão, qual grupo, quantos eventos, em que janela, qual o limiar, qual a técnica. dá pra conferir contra o log na mão.
repara também na janela: ela começa em 14:32:12, não em 14:32:11. a linha de 14:32:11 é um Invalid user admin, que não conta. isso me leva à armadilha.
a armadilha do "Invalid user" contando em dobro#
quando alguém tenta um usuário que não existe, o sshd escreve duas linhas pra mesma tentativa:
Oct 3 14:32:11 bastion01 sshd[1301]: Invalid user admin from 203.0.113.45 port 51234
Oct 3 14:32:12 bastion01 sshd[1301]: Failed password for invalid user admin from 203.0.113.45 port 51234 ssh2
se eu tratasse as duas como authentication_failure, cada tentativa com usuário inexistente valeria dois acertos na janela. no log de teste são 5 tentativas assim (admin, test, oracle, ubuntu, postgres), então 5 tentativas reais virariam 10 acertos. um atacante chutando só usuários inexistentes bateria o limiar de 10 com a metade das tentativas, e a contagem do alerta (Matched) mentiria.
eu peguei isso na hora de escrever o parser: o commit dele já registra que "Invalid user" é um tipo próprio pra AUTH-001 contar cada tentativa uma vez. a linha ganhou o tipo invalid_user, e a regra só conta authentication_failure:
case sshdInvalid.MatchString(msg):
// sshd logs "Invalid user" before the "Failed password for invalid user" of the
// same attempt; a separate type keeps AUTH-001 from counting one attempt twice.
typ, user, ip = "invalid_user", m[1], m[2]
md["reason"] = "invalid_user"
a linha Failed password for invalid user ... continua sendo authentication_failure, só que com reason: invalid_user no metadata. a informação não se perde: o evento invalid_user fica disponível pra uma regra futura que queira, por exemplo, olhar enumeração de usuários, sem poluir a contagem de falhas.
é por isso que no replay acima são 25 eventos, mas só 14 contam na detecção: 17 authentication_failure no log todo (14 do atacante, 2 da alice e 1 do bob), 3 authentication_success e 5 invalid_user.
o que eu aprendi#
- normalizar antes de detectar. a regra fala de evento, não de log. trocar o formato de entrada não mexe na detecção.
- uma tentativa é uma tentativa. quando o log tem duas linhas pro mesmo fato, modelar como tipos diferentes é mais honesto do que filtrar depois.
- tempo do evento, não do relógio. é o que deixa o resultado reproduzível e testável com um arquivo de 35 linhas.
- detecção sem explicação é só um número. mostrar regra, grupo, janela e limiar faz o alerta ser conferível.
- log real tem armadilha. o par
Invalid user/Failed password for invalid usersó aparece quando você olha umauth.logde verdade, não um evento inventado.
o código está em di0rio/sentinel-forge, no branch feat/log-parsers.