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.log
2,000 total log records
520 failed password events
23 failed-login source IPs
1 accepted login
1. Do not read first

Count and sample before scrolling. A long log needs reduction first.

2. Filter to one event

Create smaller files for failed passwords and accepted logins.

3. Group by question

Group by IP, then by IP plus username, then by one attacker.

4. Save every report

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.

1

Set the file once

log="assets/OpenSSH_2k.log"
awk 'END {print NR, FILENAME}' "$log"
2000 assets/OpenSSH_2k.log
2

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
3

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
4

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.

1

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
2

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 ...
3

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
4

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.