Count and sample before scrolling. A long log needs reduction first.
CLI Tools Mastery
SSH Log Processing Mastery
Use grep, awk, sort, uniq, and wc to turn one long OpenSSH log into a small set of answers: failed-login volume, top source IPs, targeted usernames, suspicious pairs, and accepted logins that need review.
Case File
The practice file is a real 2,000-record OpenSSH log stored in this repo. Work from the repo root when running the commands.
assets/OpenSSH_2k.logCreate smaller files for failed passwords and accepted logins.
Group by IP, then by IP plus username, then by one attacker.
Each output file becomes the input for the next check.
1. Measure The Log
Start with counts. This tells you whether the log has enough signal to justify deeper work.
Set the file once
log="assets/OpenSSH_2k.log"
awk 'END {print NR, FILENAME}' "$log"
2000 assets/OpenSSH_2k.log
Count the important event types
grep -c "Failed password" "$log"
grep -c "Accepted " "$log"
grep -c "invalid user" "$log"
grep -c "POSSIBLE BREAK-IN" "$log"
520
1
252
85
Save smaller working files
mkdir -p analysis
grep "Failed password" "$log" > analysis/failed-passwords.log
grep "Accepted " "$log" > analysis/accepted.log
wc -l analysis/*.log
1 analysis/accepted.log
520 analysis/failed-passwords.log
521 total
Sample the line shape
head -2 analysis/failed-passwords.log
Dec 10 06:55:48 LabSZ sshd[24200]: Failed password for invalid user webmaster from 173.234.31.186 port 38926 ssh2
Dec 10 07:07:45 LabSZ sshd[24206]: Failed password for invalid user test9 from 52.80.34.196 port 36060 ssh2
2. Rank The Noise
Now answer the useful question: who is trying what, and how often?
Top failed-login source IPs
awk '{
for (i = 1; i <= NF; i++)
if ($i == "from") print $(i+1)
}' analysis/failed-passwords.log |
sort |
uniq -c |
sort -rn |
head -8
286 183.62.140.253
80 187.141.143.180
46 103.99.0.122
26 112.95.230.3
18 5.188.10.180
17 185.190.58.151
7 123.235.32.19
6 119.4.203.64
Count by IP and username
awk '{
user = "unknown"
ip = "unknown"
for (i = 1; i <= NF; i++) {
if ($i == "from") ip = $(i+1)
if ($i == "for") {
if ($(i+1) == "invalid" && $(i+2) == "user") user = $(i+3)
else user = $(i+1)
}
}
if (ip != "unknown" && user != "unknown") print ip, user
}' analysis/failed-passwords.log |
sort |
uniq -c |
sort -rn > analysis/failed-by-ip-user.txt
head analysis/failed-by-ip-user.txt
276 183.62.140.253 root
46 187.141.143.180 root
24 112.95.230.3 root
15 185.190.58.151 admin
11 5.188.10.180 admin
10 103.99.0.122 admin
7 123.235.32.19 root
6 119.4.203.64 admin
6 103.99.0.122 root
5 60.2.12.12 root
First conclusion: `183.62.140.253` is the obvious brute-force source. It produced 286 failed password events, and 276 of them targeted `root`.
Save suspicious pairs
awk '$1 > 5 {print}' analysis/failed-by-ip-user.txt \
> analysis/suspicious-failed-pairs.txt
cat analysis/suspicious-failed-pairs.txt
276 183.62.140.253 root
46 187.141.143.180 root
24 112.95.230.3 root
15 185.190.58.151 admin
11 5.188.10.180 admin
10 103.99.0.122 admin
7 123.235.32.19 root
6 119.4.203.64 admin
6 103.99.0.122 root
3. Pivot On The Loudest Source
A ranked list gives a lead. A pivot explains behavior.
Pull every line for the attacker
attacker="183.62.140.253"
grep "$attacker" "$log" > analysis/attacker-183.62.140.253.log
wc -l analysis/attacker-183.62.140.253.log
858 analysis/attacker-183.62.140.253.log
Find the time window
grep "Failed password" analysis/attacker-183.62.140.253.log | head -1
grep "Failed password" analysis/attacker-183.62.140.253.log | tail -1
Dec 10 10:54:29 ... Failed password for invalid user zhangyan from 183.62.140.253 ...
Dec 10 11:04:43 ... Failed password for root from 183.62.140.253 ...
List usernames tried by that source
grep "Failed password" analysis/attacker-183.62.140.253.log |
awk '{
for (i = 1; i <= NF; i++)
if ($i == "for") {
if ($(i+1) == "invalid" && $(i+2) == "user") print $(i+3)
else print $(i+1)
}
}' |
sort |
uniq -c |
sort -rn |
head
276 root
2 oracle
1 zhangyan
1 ubuntu
1 test
1 postgres
1 git
1 dff
1 boot
Check if that source got in
grep "Accepted " analysis/attacker-183.62.140.253.log
# no output in this file
Second conclusion: the loudest source hammered `root` for about ten minutes, but this log does not show a successful login from that same IP.
Do not stop there: check accepted logins
cat analysis/accepted.log
Dec 10 09:32:20 LabSZ sshd[24680]: Accepted password for fztu from 119.137.62.142 port 49116 ssh2
The accepted login is a separate lead. It came from `119.137.62.142` for user `fztu`. Review that account, source IP, session open/close lines, and any commands or file changes available from other logs.
4. Reusable Report Script
This script repeats the same workflow and writes small report files. It does not edit the log.
ssh-log-report.sh
#!/usr/bin/env bash
set -euo pipefail
log="${1:-assets/OpenSSH_2k.log}"
out="${2:-analysis}"
threshold="${3:-5}"
mkdir -p "$out"
awk 'END {print NR, FILENAME}' "$log" > "$out/record-count.txt"
grep "Failed password" "$log" > "$out/failed-passwords.log" || true
grep "Accepted " "$log" > "$out/accepted.log" || true
awk '{
ip = "unknown"
for (i = 1; i <= NF; i++)
if ($i == "from") ip = $(i+1)
if (ip != "unknown") print ip
}' "$out/failed-passwords.log" |
sort |
uniq -c |
sort -rn > "$out/failed-by-ip.txt"
awk '{
user = "unknown"
ip = "unknown"
for (i = 1; i <= NF; i++) {
if ($i == "from") ip = $(i+1)
if ($i == "for") {
if ($(i+1) == "invalid" && $(i+2) == "user") user = $(i+3)
else user = $(i+1)
}
}
if (ip != "unknown" && user != "unknown") print ip, user
}' "$out/failed-passwords.log" |
sort |
uniq -c |
sort -rn > "$out/failed-by-ip-user.txt"
awk -v t="$threshold" '$1 > t {print}' \
"$out/failed-by-ip-user.txt" \
> "$out/suspicious-failed-pairs.txt"
printf "Wrote reports to %s\n" "$out"
Run it
chmod +x ssh-log-report.sh
./ssh-log-report.sh assets/OpenSSH_2k.log analysis 5
head analysis/failed-by-ip.txt
cat analysis/suspicious-failed-pairs.txt
cat analysis/accepted.log
Cheatsheet: Question To Command
| Question | Command pattern |
|---|---|
| How many records are in the log? | awk 'END {print NR, FILENAME}' "$log" |
| How many failed passwords? | grep -c "Failed password" "$log" |
| How many successful logins? | grep -c "Accepted " "$log" |
| Which IPs failed most? | extract word after from, then sort | uniq -c | sort -rn |
| Which username did each IP target? | extract IP plus username with awk, then count repeated pairs |
| Which pairs cross the threshold? | awk '$1 > 5 {print}' analysis/failed-by-ip-user.txt |
| What did one source do? | grep "$attacker" "$log" > analysis/attacker.log |
| Did the attacker succeed? | grep "Accepted " analysis/attacker.log |
Keep the conclusion narrow: one IP can be shared infrastructure, and distributed brute force may stay below a simple per-IP threshold. Use this workflow for fast triage, then confirm with timestamps, account activity, network logs, endpoint data, and known-good business context.