diff --git a/main.py b/main.py index 69d1667..75c6cfa 100644 --- a/main.py +++ b/main.py @@ -4,25 +4,75 @@ import sqlite3, subprocess, re, os, requests, time DB = "sshd_failures.db" STATE = "cursor.txt" -PATTERN = re.compile(r"PAM: Authentication failure for root from ([0-9a-fA-F:.]+)") - def get_cursor(): return open(STATE).read().strip() if os.path.exists(STATE) else None def save_cursor(c): - open(STATE, "w").write(c) + with open(STATE, "w") as f: + f.write(c) -def geo(ip): - print(f"Send geo request for {ip}") - r = requests.get(f"http://ip-api.com/json/{ip}", timeout=5).json() - if r["status"] == "success": - return r["country"], r["lat"], r["lon"] - return None, None, None +def init_db(db): + db.execute(""" + CREATE TABLE IF NOT EXISTS events ( + ts INTEGER, + ip TEXT, + port INTEGER, + user TEXT, + type TEXT + ) + """) + +def classify(line): + if not line or not line[0].isdigit(): + return None + + try: + ts_str, msg = line.split(" ", 1) + ts = int(float(ts_str)) + except: + return None + + m = re.search(r"Invalid user ([a-zA-Z0-9_-]+) from ([0-9a-fA-F:.]+) port (\d+)", msg) + if m: + return ts, m.group(2), int(m.group(3)), m.group(1), "invalid_user" + m = re.search(r"PAM: Authentication failure.*from ([0-9a-fA-F:.]+)", msg) + if m: + return ts, m.group(1), None, None, "pam_failure" + m = re.search(r"Received disconnect from ([0-9a-fA-F:.]+) port (\d+)", msg) + if m: + return ts, m.group(1), int(m.group(2)), None, "disconnect" + m = re.search(r"Connection closed by .* from ([0-9a-fA-F:.]+) port (\d+)", msg) + if m: + return ts, m.group(1), int(m.group(2)), None, "connection_closed" + m = re.search(r"Failed .* for (?:invalid user )?([a-zA-Z0-9_-]+) from ([0-9a-fA-F:.]+) port (\d+)", msg) + if m: + return ts, m.group(2), int(m.group(3)), m.group(1), "failed_auth" + return None + +def geo(db, ip): + print(f"Get location for {ip}") + row = db.execute("SELECT country, lat, lon FROM ip_geo WHERE ip=?", (ip,)).fetchone() + if row: + return row + try: + r = requests.get(f"http://ip-api.com/json/{ip}", timeout=5).json() + if r.get("status") == "success": + data = (r["country"], r["lat"], r["lon"]) + else: + data = (None, None, None) + except: + data = (None, None, None) + db.execute( + "INSERT OR REPLACE INTO ip_geo VALUES (?, ?, ?, ?)", + (ip, *data) + ) + time.sleep(0.1) + return data def main(): db = sqlite3.connect(DB) - db.execute("CREATE TABLE IF NOT EXISTS failures (ts INTEGER, ip TEXT)") + init_db(db) db.execute(""" CREATE TABLE IF NOT EXISTS ip_geo ( @@ -34,6 +84,7 @@ def main(): """) cmd = ["journalctl", "-u", "sshd", "-o", "short-unix", "--no-pager"] + cur = get_cursor() if cur: cmd += ["--after-cursor", cur] @@ -41,45 +92,25 @@ def main(): out = subprocess.check_output(cmd, text=True) lines = out.splitlines() - print(f"Must parse {len(lines)}") + print(f"Parsing {len(lines)} lines") + for line in lines: - print(line) - if not line or not line[0].isdigit(): + result = classify(line) + if not result: continue - ts_str, msg = line.split(" ", 1) + ts, ip, port, user, typ = result - try: - ts = int(float(ts_str)) - except ValueError: - continue + print(f"{typ:18} ip={ip} port={port} user={user}") - m = PATTERN.search(msg) - if not m: - continue - - ip = m.group(1) - - # store raw event - print(f"Inserting {ip}") - db.execute("INSERT INTO failures VALUES (?, ?)", (ts, ip)) - - # check cache - cached = db.execute( - "SELECT 1 FROM ip_geo WHERE ip = ?", - (ip,) - ).fetchone() - - if not cached: - country, lat, lon = geo(ip) - db.execute( - "INSERT OR REPLACE INTO ip_geo VALUES (?, ?, ?, ?)", - (ip, country, lat, lon) - ) - time.sleep(0.2) + db.execute( + "INSERT INTO events VALUES (?, ?, ?, ?, ?)", + (ts, ip, port, user, typ) + ) + if ip: + geo(db, ip) db.commit() - db.close() # save cursor out = subprocess.check_output( @@ -92,6 +123,9 @@ def main(): save_cursor(l.split("=", 1)[1]) break + db.close() + print("Done") + if __name__ == "__main__": main() - print("Parsed everything") + print("All done") diff --git a/stats.py b/stats.py index 48c539d..290f30f 100644 --- a/stats.py +++ b/stats.py @@ -4,30 +4,114 @@ from datetime import datetime db = sqlite3.connect("sshd_failures.db") -for row in db.execute(""" +def ts(x): + return datetime.fromtimestamp(x).strftime("%Y-%m-%d %H:%M:%S") if x else "N/A" + +def bar(n, mx, width=40): + if not n or mx == 0: + return "" + return "ā–ˆ" * max(1, int((n / mx) * width)) + +print("\n" + "═"*100) +print("šŸ” SSH SECURITY INTELLIGENCE DASHBOARD") +print("═"*100) + +total = db.execute("SELECT COUNT(*) FROM events").fetchone()[0] +ips = db.execute("SELECT COUNT(DISTINCT ip) FROM events").fetchone()[0] +countries = db.execute(""" +SELECT COUNT(DISTINCT COALESCE(country,'Unknown')) +FROM ip_geo +""").fetchone()[0] + +print(f"\nšŸ“Š EVENTS: {total} 🌐 IPS: {ips} šŸŒ COUNTRIES: {countries}") + +print("\n" + "─"*100) +print("šŸ”„ ATTACK TYPES") +print("─"*100) + +types = list(db.execute(""" +SELECT type, COUNT(*) c +FROM events +GROUP BY type +ORDER BY c DESC +""")) + +mx = max([c for _, c in types] or [1]) +for t, c in types: + print(f"{t:20} {c:6} {bar(c, mx)}") + +print("\n" + "─"*100) +print("🚨 TOP ATTACKING IPS") +print("─"*100) + +ips_top = list(db.execute(""" SELECT - g.country, - COUNT(*) AS total, - MAX(f.ts) AS last_seen, - ( - SELECT f2.ip - FROM failures f2 - JOIN ip_geo g2 ON f2.ip = g2.ip - WHERE g2.country = g.country - GROUP BY f2.ip + e.ip, + COUNT(*) c, + MAX(e.ts) last, + COALESCE(g.country,'Unknown') country +FROM events e +LEFT JOIN ip_geo g ON e.ip = g.ip +GROUP BY e.ip +ORDER BY c DESC +LIMIT 15 +""")) + +mx = max([c for _, c, _, _ in ips_top] or [1]) +for ip, c, last, country in ips_top: + print(f"{ip:18} {c:6} {country:15} {bar(c, mx)} last={ts(last)}") + +print("\n" + "─"*100) +print("šŸŒ COUNTRY HEATMAP") +print("─"*100) + +countries_rows = list(db.execute(""" +SELECT + COALESCE(g.country,'Unknown') country, + COUNT(*) total, + COUNT(DISTINCT e.ip) ips, + MAX(e.ts) last +FROM events e +JOIN ip_geo g ON e.ip = g.ip +GROUP BY country +ORDER BY total DESC +""")) + +mx = max([c for _, c, _, _ in countries_rows] or [1]) + +for country, total_c, ip_count, last in countries_rows: + top_ip = db.execute(""" + SELECT e.ip + FROM events e + JOIN ip_geo g ON e.ip = g.ip + WHERE COALESCE(g.country,'Unknown') = ? + GROUP BY e.ip ORDER BY COUNT(*) DESC LIMIT 1 - ) AS top_ip -FROM failures f -JOIN ip_geo g ON f.ip = g.ip -GROUP BY g.country -ORDER BY total DESC -"""): - country, total, last_seen, top_ip = row + """, (country,)).fetchone() - if last_seen: - last_seen = datetime.fromtimestamp(last_seen) + top_ip = top_ip[0] if top_ip else "Unknown" - print(f"{country:25} {total:6} top_ip={top_ip:18} last={last_seen}") + print(f"{country:25} {total_c:6} ips={ip_count:4} top={top_ip:16} {bar(total_c, mx)} last={ts(last)}") -db.close() +print("\n" + "─"*100) +print("🧠 TOP USERNAMES") +print("─"*100) + +users = list(db.execute(""" +SELECT COALESCE(user,'unknown') user, COUNT(*) c +FROM events +WHERE user IS NOT NULL +GROUP BY user +ORDER BY c DESC +LIMIT 15 +""")) + +mx = max([c for _, c in users] or [1]) + +for u, c in users: + print(f"{u:20} {c:6} {bar(c, mx)}") + +print("\n" + "═"*100) +print("āœ… DONE") +print("═"*100)