Shieldlist

openssh Free

SSH brute force, key-only hosts included — two rules, one ladder.

Download the pack tar.gz of the files listed below

Rules (2)

About this pack

sshd, read from a syslog file (/var/log/auth.log) written in RFC3339 — rsyslog's RSYSLOG_FileFormat, the Debian 12+ default:

2026-08-14T09:53:19.686208+02:00 host sshd[3168858]: Invalid user sftp_user from 203.0.113.9 port 38368

Category ssh. Every parser captures the line's own timestamp, so -dry-run can judge a log's past.

The traditional format (Aug 14 09:53:19 host sshd-session[…]) is read by the same parsers — the regexes accept either timestamp and sshd or sshd-session (OpenSSH 9.8+, Debian 13) — but the line's own time is not understood then (time_format is rfc3339), so the agent dates each hit by the moment it read it: right for a live tail, wrong for -dry-run on an old file. For that, set time_format: syslog in copies of the parsers. Reading journald instead (unit: ssh.service) means the message has no syslog prefix at all: drop the leading ^(?P<time>…) \S+ sshd(?:-session)?\[\d+\]: and the two time keys — journald carries its own clock. The agent's shipped examples/ssh is that variant.

Usernames are attacker-controlled

sshd logs the username it was given, and a username may contain spaces. Every pattern here is therefore anchored at the end of the line, with the username running greedily, so that from <address> port <n> binds to the last occurrence — the one sshd wrote. Without that, connecting as x from 198.51.100.1 port 22 ssh2 gets a bystander banned.

Keep that property in anything derived from these.

Parsers (8)

The building blocks: each parser names a log, a cheap prefilter and the regex that extracts the address.

ssh-auth-abandoned ssh
# An authentication that was under way for a NAMED account when the
# client gave up:
#   Connection closed by authenticating user root 203.0.113.9 port 56390 [preauth]
#   Disconnected from invalid user vlad 203.0.113.9 port 57134 [preauth]
#
# This is a login attempt. sshd only writes "authenticating user X" once
# a client has offered an account and the exchange has begun, so the
# connection was not a port scan and not a monitor: someone tried, and
# stopped when they did not get in. On a key-only host they were never
# going to.
#
# Worth the same as a refused password, and the reason this parser
# exists at all: it used to be folded into ssh-preauth-abort, where the
# weight had to suit a bare dropped connection, and a real bot working
# through root then scored one point out of five and walked. Keeping
# the named-account lines apart is what lets each carry its own weight.
#
# The account name is chosen by the client, so the pattern is anchored
# at the end of the line and lets it run greedily: " <address> port <n>
# [preauth]" then binds to the last one on the line, which is sshd's.
parser: ssh-auth-abandoned
log: ssh
prefilter: "[preauth]"
regex: '^(?P<time>\S+(?: +\d+ \d\d:\d\d:\d\d)?) \S+ sshd(?:-session)?\[\d+\]: (?:Connection closed by|Connection reset by|Disconnected from) (?:authenticating user|invalid user) (?P<user>.*) (?P<ip>[0-9a-fA-F.:]+) port \d+ \[preauth\]$'
time_field: time
time_format: rfc3339
ssh-auth-fail ssh
# A password refused, for a real account or an invented one:
#   Failed password for root from 203.0.113.9 port 51266 ssh2
#   Failed password for invalid user admin from 203.0.113.9 port 51266 ssh2
#
# The username is chosen by whoever is connecting and may contain
# spaces, so the pattern is anchored at the end of the line and lets the
# username run greedily: " from <address> port <n> ssh2" then binds to
# the last one on the line, which is the one sshd wrote. Without that, a
# user called "x from 198.51.100.1 port 22 ssh2" gets someone else
# banned.
parser: ssh-auth-fail
log: ssh
prefilter: "Failed password"
regex: '^(?P<time>\S+(?: +\d+ \d\d:\d\d:\d\d)?) \S+ sshd(?:-session)?\[\d+\]: Failed password for (?:invalid user )?(?P<user>.*) from (?P<ip>\S+) port \d+ ssh2$'
time_field: time
time_format: rfc3339
ssh-auth-timeout ssh
# A connection held open without authenticating until sshd gave up:
#   fatal: Timeout before authentication for 203.0.113.9 port 22
#
# The signature of a regreSSHion (CVE-2024-6387) attempt, which needs
# thousands of them, and of nothing a client does on purpose.
parser: ssh-auth-timeout
log: ssh
prefilter: "Timeout before authentication"
regex: '^(?P<time>\S+(?: +\d+ \d\d:\d\d:\d\d)?) \S+ sshd(?:-session)?\[\d+\]: .*Timeout before authentication for (?P<ip>[0-9a-fA-F.:]+) port \d+$'
time_field: time
time_format: rfc3339
ssh-banner-invalid ssh
# Something that is not an SSH client talking to the SSH port:
#   banner exchange: Connection from 203.0.113.9 port 50920: invalid format
#
# The companion line names what was sent ("MGLNDD_<host>_22", "GET /
# HTTP/1.1") but carries no address, so this is the line worth reading.
# Port scanners and internet-wide surveys produce nearly all of it.
parser: ssh-banner-invalid
log: ssh
prefilter: "banner exchange"
regex: '^(?P<time>\S+(?: +\d+ \d\d:\d\d:\d\d)?) \S+ sshd(?:-session)?\[\d+\]: banner exchange: Connection from (?P<ip>[0-9a-fA-F.:]+) port \d+: invalid format$'
time_field: time
time_format: rfc3339
ssh-invalid-user ssh
# A username that does not exist here:
#   Invalid user sftp_user from 203.0.113.9 port 38368
#
# On a key-only host this is most of what an attack looks like: the
# password never gets as far as being refused.
parser: ssh-invalid-user
log: ssh
prefilter: "Invalid user"
regex: '^(?P<time>\S+(?: +\d+ \d\d:\d\d:\d\d)?) \S+ sshd(?:-session)?\[\d+\]: Invalid user (?P<user>.*) from (?P<ip>\S+) port \d+$'
time_field: time
time_format: rfc3339
ssh-preauth-abort ssh
# A connection dropped before authentication, WITH NOBODY NAMED:
#   Connection closed by 203.0.113.9 port 47782 [preauth]
#   Disconnected from 203.0.113.9 port 57134 [preauth]
#
# Weak evidence on purpose. A port scanner writes these, and so does a
# monitoring probe checking the port is open, and so does a phone on a
# bad connection. Nothing says anyone tried to get in.
#
# The moment sshd names an account, it is a different line and a
# different parser: see ssh-auth-abandoned, which is worth as much as a
# refused password. Keeping them apart is the whole point — merged, the
# weight had to suit the weaker one, and a real attempt on root scored
# a single point.
parser: ssh-preauth-abort
log: ssh
prefilter: "[preauth]"
regex: '^(?P<time>\S+(?: +\d+ \d\d:\d\d:\d\d)?) \S+ sshd(?:-session)?\[\d+\]: (?:Connection closed by|Connection reset by|Disconnected from) (?P<ip>[0-9a-fA-F.:]+) port \d+ \[preauth\]$'
time_field: time
time_format: rfc3339
ssh-root-fail ssh
# A failed password FOR ROOT specifically. root is a real account, so
# the line has no "invalid user" — a bot guessing root's password writes
# "Failed password for root from <ip>". On a correctly run server root
# either cannot log in over SSH at all or takes a key only: a password
# attempt on root is never a fat-fingered admin, it is an attack, and it
# earns a faster verdict than the general brute-force jail (Robin,
# 2026-08-20: "ban au 2e essai"). The general ssh rule counts it too.
parser: ssh-root-fail
log: ssh
prefilter: "Failed password for root from"
regex: '^(?P<time>\S+(?: +\d+ \d\d:\d\d:\d\d)?) \S+ sshd(?:-session)?\[\d+\]: Failed password for root from (?P<ip>\S+) port \d+ ssh2$'
time_field: time
time_format: rfc3339
ssh-root-preauth ssh
# A root attempt that ended at preauth — "Connection closed/reset by
# authenticating user root", "Disconnected from authenticating user
# root". On a key-only host (no password accepted) this is the ONLY
# trace a bot guessing root leaves: no "Failed password" is ever
# written. root over SSH is never a legitimate password client, so this
# counts toward the two-strike root jail exactly like a failed password
# (Robin, 2026-08-20: the preauth lines were being missed). "root"
# exactly — a username like "root2" reads as its own name, not this.
parser: ssh-root-preauth
log: ssh
prefilter: "authenticating user root"
regex: '^(?P<time>\S+(?: +\d+ \d\d:\d\d:\d\d)?) \S+ sshd(?:-session)?\[\d+\]: (?:Connection closed by|Connection reset by|Disconnected from) authenticating user root (?P<ip>[0-9a-fA-F.:]+) port \d+ \[preauth\]$'
time_field: time
time_format: rfc3339

Ban policies

ssh 6h → 24h → 7d
#
# The ladder the ssh rule climbs. A first offence is six hours — sshd is
# nobody's door but the administrator's, and an address that failed
# three times in five minutes is not the administrator. One that comes
# back after its ban ran out has proved it means it, and climbs; the
# last rung repeats for ever. Quiet for thirty days after a ban ENDED
# (never after it started) and the next offence starts at the bottom.
#
# tighten: each past episode within decay also halves the hits the rule
# needs — the second visit convicts on 2 of 3, the third on 1. Remove
# the line to keep the thresholds fixed.
policy: ssh
durations: [6h, 24h, 7d]
ports: service
memory: 30d
tighten: 2x

Reports

ssh-brute-force Unauthorised SSH login attempts
#
# What a report about this says, if reporting is turned on at all.
#
# `include` is the redaction, and it is a list of what MAY leave rather
# than a filter of what must not. A field this does not name cannot
# appear in a report, whatever a log line happens to contain — which
# matters here because the parsers capture the account names that were
# tried, and those are none of a public database's business. Somebody
# typed their own username into the wrong host once; that is not a
# thing to publish under your name.
#
# The offender's address is always sent. It is what a report *is*.
report: ssh-brute-force
categories: [brute-force, ssh]
says: "Unauthorised SSH login attempts"

# Nothing else leaves. The sentence, the count and the window are
# enough for anyone reading the report to know what happened — and the
# provider's own guidance asks for exactly that rather than a log dump.
include: []

Files