initial commit
This commit is contained in:
commit
18dc520591
9 changed files with 385 additions and 0 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
bin
|
||||
lib
|
||||
lib64
|
||||
include
|
||||
__pycache__
|
||||
*.cfg
|
||||
*.toml
|
||||
*.log
|
||||
153
README.md
Normal file
153
README.md
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
# 🚨 sshd-bruteforce-detector
|
||||
|
||||
A lightweight, real-time SSH brute-force detection tool for Linux systems using `systemd-journal`.
|
||||
|
||||
It monitors `sshd` logs, groups failed login attempts by source IP, and alerts you when suspicious activity crosses a defined threshold.
|
||||
|
||||
Because manually reading logs is fun… until it isn’t.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* 📡 Real-time monitoring via `systemd.journal`
|
||||
* 🔍 Detects repeated failed SSH authentication attempts
|
||||
* 🌐 Groups events per remote host (IP)
|
||||
* ⏱ Configurable time window (e.g. `5m`, `1h`)
|
||||
* 🚦 Configurable max attempts threshold
|
||||
* 🧾 Supports config file (`.toml`) and CLI args
|
||||
* 🪶 Lightweight and dependency-minimal
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ How It Works
|
||||
|
||||
1. Listens to `sshd.service` logs from systemd journal
|
||||
2. Filters **failed authentication messages**
|
||||
3. Groups attempts by `rhost` (IP address)
|
||||
4. Keeps only recent attempts within a time window
|
||||
5. Triggers alert if attempts ≥ threshold
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/sshd-bruteforce-detector.git
|
||||
cd sshd-bruteforce-detector
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
> Requires Linux with `systemd` and Python 3.11+ (for `tomllib`).
|
||||
|
||||
---
|
||||
|
||||
## ▶️ Usage
|
||||
|
||||
### Basic run
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
### With arguments
|
||||
|
||||
```bash
|
||||
python main.py -w 5m -m 10
|
||||
```
|
||||
|
||||
### With config file
|
||||
|
||||
```bash
|
||||
python main.py -c config.toml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Configuration
|
||||
|
||||
You can configure via CLI or TOML file.
|
||||
|
||||
### Example `config.toml`
|
||||
|
||||
```toml
|
||||
window = "5m"
|
||||
max_attempts = 5
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Description | Default |
|
||||
| ---------------------- | ------------------------------------------------- | ------- |
|
||||
| `--window`, `-w` | Time window to group attempts (`1h`, `5m`, `30s`) | `5m` |
|
||||
| `--max-attempts`, `-m` | Max failed attempts before alert | `5` |
|
||||
| `--config`, `-c` | Path to TOML config file | — |
|
||||
|
||||
CLI args override config file values.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Example Output
|
||||
|
||||
```text
|
||||
INFO Authentication failure from host 192.168.1.10 for user root on sshd
|
||||
INFO Authentication failure from host 192.168.1.10 for user admin on sshd
|
||||
CRITICAL 5 authentication failure events from 192.168.1.10 on sshd in 42 seconds.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Detection Logic
|
||||
|
||||
The detection is intentionally simple:
|
||||
|
||||
```python
|
||||
len(recent_attempts) >= max_attempts
|
||||
```
|
||||
|
||||
No AI, no magic. Just effective signal over noise.
|
||||
|
||||
---
|
||||
|
||||
## 🛑 Stopping the Program
|
||||
|
||||
Handles signals gracefully:
|
||||
|
||||
* `Ctrl + C` → exits with code `130`
|
||||
* `SIGTERM` → clean exit
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── main.py # Entry point
|
||||
├── detector.py # Detection logic
|
||||
├── config.py # CLI + config handling
|
||||
├── message_parse.py # Log parsing
|
||||
├── utils.py # Helpers (window parsing, filtering)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Limitations
|
||||
|
||||
* Only works on systems using `systemd`
|
||||
* Depends on log format consistency (`authentication failure;`)
|
||||
* No automatic banning (yet 👀)
|
||||
|
||||
---
|
||||
|
||||
## 💡 Future Ideas
|
||||
|
||||
* Auto-ban IPs via `iptables` / `nftables`
|
||||
* Export metrics (Prometheus)
|
||||
* Web dashboard
|
||||
* Alert integrations (Slack, email, etc.)
|
||||
|
||||
---
|
||||
|
||||
## 📜 License
|
||||
|
||||
MIT — do whatever you want, just don’t blame me if your server catches fire.
|
||||
66
config.py
Normal file
66
config.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import argparse
|
||||
import sys
|
||||
import tomllib
|
||||
import utils
|
||||
import logging
|
||||
|
||||
|
||||
class Config:
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger("sshd-bruteforce-detector")
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="sshd-bruteforce-detector",
|
||||
description="detects SSH bruteforce attacks by reading sshd's logs from systemd journal",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-c", "--config", nargs="?", help="provide a path for the config file"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-m",
|
||||
"--max-attempts",
|
||||
nargs="?",
|
||||
help="Maximum number of failed login attempts allowed within the time window before triggering an alert. (default: 5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-w",
|
||||
"--window",
|
||||
nargs="?",
|
||||
help="Time window used to group failed login attempts for detection (default: 5m)",
|
||||
)
|
||||
|
||||
self.args = parser.parse_args()
|
||||
self.config = {}
|
||||
if getattr(self.args, "config", None):
|
||||
self.__load_config(self.args.config)
|
||||
|
||||
def __load_config(self, path: str):
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
self.config = tomllib.load(f)
|
||||
except FileNotFoundError:
|
||||
self.logger.info("Config file not found. Stopping manually...")
|
||||
sys.exit(1)
|
||||
except tomllib.TOMLDecodeError:
|
||||
self.logger.info(
|
||||
"Config file not in valid TOML format. Stopping manually..."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
def __get_config_option(self, option):
|
||||
opt = getattr(self.args, option, None)
|
||||
opt_config = self.config.get(option, None)
|
||||
if opt is not None:
|
||||
return opt
|
||||
elif opt_config is not None:
|
||||
return opt_config
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_window(self):
|
||||
window = self.__get_config_option("window")
|
||||
return utils.parse_window(window) if window is not None else 300
|
||||
|
||||
def get_max_attempts(self):
|
||||
max_attempts = self.__get_config_option("max_attempts")
|
||||
return int(max_attempts) if max_attempts is not None else 5
|
||||
5
detector.py
Normal file
5
detector.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from message_parse import Message
|
||||
|
||||
|
||||
def detect_suspect_messages(recent_messages: list[Message], max_events: int):
|
||||
return len(recent_messages) >= max_events
|
||||
23
logs.py
Normal file
23
logs.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
def setup_logging():
|
||||
logger = logging.getLogger("sshd-bruteforce-detector")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
|
||||
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console.setFormatter(fmt)
|
||||
|
||||
file = logging.FileHandler("sshd-bruteforce-detector.log")
|
||||
file.setFormatter(fmt)
|
||||
|
||||
logger.addHandler(console)
|
||||
logger.addHandler(file)
|
||||
|
||||
return logger
|
||||
89
main.py
Normal file
89
main.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import logging
|
||||
from systemd import journal
|
||||
from datetime import timedelta
|
||||
from config import Config
|
||||
from utils import filter_message_list
|
||||
from message_parse import Message
|
||||
from sys import exit
|
||||
import detector
|
||||
import signal
|
||||
import time
|
||||
import logs
|
||||
|
||||
|
||||
running = True
|
||||
logger = logging.getLogger("sshd-bruteforce-detector")
|
||||
|
||||
|
||||
def handle_exit_signal(signum, _):
|
||||
global running
|
||||
global logger
|
||||
running = False
|
||||
logger.info(f"{signal.Signals(signum).name} received. Stopping manually...")
|
||||
if signum == signal.SIGINT:
|
||||
exit(130)
|
||||
else:
|
||||
exit(0)
|
||||
|
||||
|
||||
def main():
|
||||
logs.setup_logging()
|
||||
|
||||
global logger
|
||||
|
||||
config = Config()
|
||||
|
||||
logger = logging.getLogger("sshd-bruteforce-detector")
|
||||
window = config.get_window()
|
||||
max_attempts = config.get_max_attempts()
|
||||
|
||||
j = journal.Reader()
|
||||
|
||||
j.add_match(_SYSTEMD_UNIT="sshd.service")
|
||||
j.seek_tail()
|
||||
j.get_previous()
|
||||
|
||||
messages: dict[str, list] = {}
|
||||
|
||||
message_detected = False
|
||||
logger.info(
|
||||
f"Starting sshd-bruteforce-detector with configuration: window={window} max_attempts={max_attempts}"
|
||||
)
|
||||
|
||||
global running
|
||||
|
||||
while running:
|
||||
for entry in j:
|
||||
message_str = entry["MESSAGE"]
|
||||
if Message.is_failed_auth_message(message_str):
|
||||
message = Message(message_str, entry["__REALTIME_TIMESTAMP"])
|
||||
if not messages.get(str(message.rhost)):
|
||||
messages[str(message.rhost)] = []
|
||||
messages[str(message.rhost)].append(message)
|
||||
logger.info(
|
||||
f"Authentication failure from host {message.rhost} for user {message.user} on sshd"
|
||||
)
|
||||
message_detected = True
|
||||
|
||||
new_messages = messages.copy()
|
||||
for ip, message_list in messages.items():
|
||||
new_messages[ip] = filter_message_list(
|
||||
message_list, timedelta(seconds=window)
|
||||
)
|
||||
if (
|
||||
detector.detect_suspect_messages(new_messages[ip], max_attempts)
|
||||
and message_detected
|
||||
):
|
||||
alert_window = new_messages[ip][-1].date - new_messages[ip][0].date
|
||||
logger.critical(
|
||||
f"{len(new_messages[ip])} authentication failure events from {ip} on sshd in {alert_window.seconds} seconds."
|
||||
)
|
||||
message_detected = False
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
signal.signal(signal.SIGTERM, handle_exit_signal)
|
||||
signal.signal(signal.SIGINT, handle_exit_signal)
|
||||
main()
|
||||
16
message_parse.py
Normal file
16
message_parse.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from datetime import datetime
|
||||
import re
|
||||
|
||||
|
||||
class Message:
|
||||
def __init__(self, message: str, date: datetime):
|
||||
self.date = date
|
||||
self.user = re.search(r"user=([^\s]+)", message)
|
||||
self.rhost = re.search(r"rhost=([^\s]+)", message)
|
||||
|
||||
self.user = self.user.group(1) if self.user else None
|
||||
self.rhost = self.rhost.group(1) if self.rhost else None
|
||||
|
||||
@staticmethod
|
||||
def is_failed_auth_message(message: str):
|
||||
return "authentication failure;" in message
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
systemd-python
|
||||
24
utils.py
Normal file
24
utils.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
from message_parse import Message
|
||||
import re
|
||||
|
||||
|
||||
def filter_message_list(message_list: list[Message], window: timedelta):
|
||||
now = datetime.now(timezone.utc)
|
||||
return [m for m in message_list if now - m.date <= window]
|
||||
|
||||
|
||||
def parse_window(s: str) -> int:
|
||||
pattern = r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$"
|
||||
match = re.fullmatch(pattern, s.strip())
|
||||
|
||||
if not match:
|
||||
raise ValueError(f"Invalid duration: {s}")
|
||||
|
||||
h, m, sec = match.groups()
|
||||
|
||||
return (
|
||||
(int(h) * 3600 if h else 0)
|
||||
+ (int(m) * 60 if m else 0)
|
||||
+ (int(sec) if sec else 0)
|
||||
)
|
||||
Loading…
Reference in a new issue