auth-log-scan · synthetic OpenSSH log · 14 March 2026
This page is the output of the scanner itself. It reads
auth-demo.log, flags brute-force sources, username enumeration and logins that
succeed from an address that had been failing, and everything below is what came back.
163 lines, of which 146 are sshd authentication events — the rest are CRON jobs, sudo calls and disconnects that the parser skips rather than guesses at. Placed on one clock, the day is mostly quiet, and the pressure arrives in bursts minutes long.
Invalid user
lines.
Counting failures per address would flag the wrong things. A shared host collects a handful of typos a day, while an attacker produces the same number in a second. The detector therefore reports the densest stretch: the largest number of failures from one address inside any window-length span, and flags it when that peak reaches the threshold.
Each lane below is zoomed to its own source, so a window that is a third of a pixel wide across the whole day becomes visible. The band is the window; the marks inside it are the count being compared.
Shown at the defaults the CLI uses: threshold 5, window 60 seconds. With JavaScript on, the two settings become sliders over results this build already computed.
| source address | peak in window | failures | accounts | first | last |
|---|---|---|---|---|---|
| 203.0.113.42 | 31 | 40 | 1 | 04:02:00 | 04:03:18 |
| 203.0.113.7 | 21 | 24 | 5 | 05:31:00 | 05:32:09 |
| 2001:db8:2::19 | 7 | 7 | 1 | 07:15:00 | 07:15:24 |
| 198.51.100.23 | 6 | 12 | 1 | 06:40:00 | 06:42:01 |
At the default settings — threshold 5, window 60 seconds —
4 sources are flagged. Widen the window to 15m and
198.51.100.77 joins them: its attempts are spread far enough apart that a
60-second window never holds more than 1 of them, which is what
a slow scan is for. Raise the threshold above 6 and 198.51.100.23
drops out — the same evidence, read more strictly.
analyze.pydef _max_in_window(sorted_times: List[datetime], window: timedelta) -> Tuple[int, int]:
"""Largest count of timestamps within any window-length span, and where it starts.
Input is sorted ascending. Returns ``(count, index)`` — the index being the position of
the first timestamp of the densest span, so a caller can say *when* the peak happened
and not only how large it was. On ties the earliest span wins.
"""
left = 0
best = 0
best_left = 0
for right in range(len(sorted_times)):
while sorted_times[right] - sorted_times[left] > window:
left += 1
if right - left + 1 > best:
best = right - left + 1
best_left = left
return best, best_left
Success is not by itself interesting; success from a source that has just failed 5 times or more is. Only failures recorded before the accepted login count, so an ordinary login followed later by unrelated failures is not flagged.
| source address | account | when | failures before |
|---|---|---|---|
| 203.0.113.7 | svc-backup | 05:32:15 | 24 |
On a real host this is the row to read first: it is the difference between an attack that was attempted and one that worked.
24 of the accounts tried do not exist on the host — the signature of enumeration rather than of a user who forgot a password: admin, ansible, backup, docker, elastic, ftp, git, guest, jenkins, mysql, nagios, operator, oracle, pi, postgres, redis, support, teamcity, test, tomcat, ubuntu, vagrant, www-data, zabbix.
The page and the terminal report come from the same call. This is the plain output, at
the default settings, of auth-log-scan auth-demo.log:
== auth-log-scan report ==
events parsed : 146
time range : 2026-03-14 00:12:03 .. 2026-03-14 07:16:40
failed / accepted / invalid-user : 108 / 5 / 33
[!] brute-force sources (4)
source_ip max/win win(s) total users first last
-------------- ------- ------ ----- ----- ------------------- -------------------
203.0.113.42 31 60 40 1 2026-03-14 04:02:00 2026-03-14 04:03:18
203.0.113.7 21 60 24 5 2026-03-14 05:31:00 2026-03-14 05:32:09
2001:db8:2::19 7 60 7 1 2026-03-14 07:15:00 2026-03-14 07:15:24
198.51.100.23 6 60 12 1 2026-03-14 06:40:00 2026-03-14 06:42:01
[!] suspicious successes (1)
source_ip user when prior_fails
----------- ---------- ------------------- -----------
203.0.113.7 svc-backup 2026-03-14 05:32:15 24
top attacked usernames
user attempts
---------- --------
root 44
deploy 12
oracle 8
svc-backup 8
admin 5
postgres 5
jenkins 5
bob 1
test 1
git 1
top source IPs by failures
source_ip failures
-------------- --------
203.0.113.42 40
198.51.100.77 24
203.0.113.7 24
198.51.100.23 12
2001:db8:2::19 7
192.0.2.11 1
invalid usernames probed (24): admin, ansible, backup, docker, elastic, ftp, git, guest, jenkins, mysql, nagios, operator, oracle, pi, postgres, redis, support, teamcity, test, tomcat, ubuntu, vagrant, www-data, zabbix
The data. The log is synthetic — written for this demo, using the RFC 5737 and RFC 3849 documentation address ranges and invented account names. No real log, from any host, is published here.
The numbers. Every figure on this page is produced by the same
parse and analyze functions the CLI calls. The sliders move
between results computed at build time — one full scan per window length — and the
browser only repeats the detector's own comparison of peak against threshold. CI
rebuilds this page and fails if it differs from the committed copy, so a figure here
cannot drift away from the code.
The limits. OpenSSH in the traditional syslog format only
(auth.log, secure); journald and other daemons are out of
scope. The analysis is stateless and single-file: no persistence, no live tailing, no
reputation data. A flagged address is a source of many failures in a short time, which
is evidence of an attempt — not proof of a compromise, and not a firewall rule.