auth-log-scan · synthetic OpenSSH log · 14 March 2026

108 failed logins in 7.1 hours — and only some of them are an attack

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.

What the log holds

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.

Authentication events by source address over the day01:0002:0003:0004:0005:0006:0007:00203.0.113.42198.51.100.77203.0.113.7198.51.100.232001:db8:2::19192.0.2.11192.0.2.10192.0.2.31
Every recognised event between 00:12:03 and 07:16:40, one lane per source address. 108 failed, 5 accepted, 33 explicit Invalid user lines.

The rule: a sliding window, not a total

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.

315
15s1h

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.

Failures per source with the densest window banded203.0.113.4231 in 60s ≥ 5198.51.100.771 in 60s < 5203.0.113.721 in 60s ≥ 5198.51.100.236 in 60s ≥ 52001:db8:2::197 in 60s ≥ 5192.0.2.111 in 60s < 5192.0.2.100 in 60s < 5192.0.2.310 in 60s < 5
One lane per source, each on its own time axis — the lanes are not comparable to each other in width, only in what happens inside them. The verdict beside each lane is the comparison the detector makes: peak against threshold.

Flagged sources

source address peak in window failures accounts first last
203.0.113.423140104:02:0004:03:18
203.0.113.72124505:31:0005:32:09
2001:db8:2::1977107:15:0007:15:24
198.51.100.23612106:40:0006: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.

The rule as code, from analyze.py
def _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

A login that succeeded from an address that had been failing

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 addressaccountwhenfailures before
203.0.113.7svc-backup05:32:1524

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.

Which accounts were probed

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.

Accounts by failed attempts (attempts)root44deploy12oracle8svc-backup8admin5postgres5jenkins5bob1test1git1
Failed attempts per account name. A name nobody created cannot produce a typo, so every attempt against one is deliberate.

Where the failures came from

Source addresses by failed logins (failures)203.0.113.4240198.51.100.7724203.0.113.724198.51.100.23122001:db8:2::197192.0.2.111
Failed logins per source address. Addresses are treated as opaque strings — IPv4 and IPv6 alike — and never resolved or geolocated.

What the tool prints

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

Method, and what it cannot tell you

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.