added
هذا الالتزام موجود في:
302
q4-siem/siem.py
Normal file
302
q4-siem/siem.py
Normal file
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
siem.py — Simplified SIEM for Ghaymah Storage
|
||||
Collects logs from 3 sources, analyzes for suspicious patterns,
|
||||
and exposes alerts as JSON for the dashboard.
|
||||
"""
|
||||
|
||||
import re
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import random
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
# ─── Configuration ─────────────────────────────────────────────
|
||||
LOG_SOURCES = {
|
||||
"auth": "/mnt/logs/auth.log",
|
||||
"nginx": "/mnt/logs/nginx/access.log",
|
||||
"app": "/mnt/logs/app/application.log",
|
||||
}
|
||||
OUTPUT_FILE = "/var/www/siem/alerts.json"
|
||||
ALERT_THRESHOLD_FAIL = 5 # Failed logins before alert
|
||||
ALERT_THRESHOLD_RATE = 100 # HTTP requests/min before alert
|
||||
SCAN_INTERVAL_SEC = 30 # How often to re-scan logs
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s"
|
||||
)
|
||||
log = logging.getLogger("siem")
|
||||
|
||||
# ─── Patterns ──────────────────────────────────────────────────
|
||||
PATTERNS = {
|
||||
"brute_force": re.compile(
|
||||
r"Failed password for .+ from (?P<ip>\d+\.\d+\.\d+\.\d+)"
|
||||
),
|
||||
"invalid_user": re.compile(
|
||||
r"Invalid user .+ from (?P<ip>\d+\.\d+\.\d+\.\d+)"
|
||||
),
|
||||
"nginx_request": re.compile(
|
||||
r'(?P<ip>\d+\.\d+\.\d+\.\d+) .+ "(?P<method>\w+) (?P<path>\S+) HTTP.+" (?P<status>\d{3})'
|
||||
),
|
||||
"app_error": re.compile(
|
||||
r"\[(?P<level>ERROR|CRITICAL)\] (?P<msg>.+)"
|
||||
),
|
||||
"sql_injection": re.compile(
|
||||
r"\b(select|union|insert|drop|update|delete|benchmark|sleep)\b\s*[\(%'\"()]",
|
||||
re.IGNORECASE
|
||||
),
|
||||
}
|
||||
|
||||
# ─── State ─────────────────────────────────────────────────────
|
||||
failed_logins: dict = defaultdict(int) # ip → count
|
||||
request_counts: dict = defaultdict(int) # ip → count/window
|
||||
sql_attempts: list = []
|
||||
alerts: list = []
|
||||
|
||||
|
||||
def generate_sample_logs():
|
||||
"""
|
||||
Generate sample log lines (used when real log files don't exist).
|
||||
This allows the SIEM to demonstrate functionality without root access.
|
||||
"""
|
||||
attacker_ip = "185.220.100.42"
|
||||
legit_ip = "192.168.1.10"
|
||||
internal_ip = "10.0.0.5"
|
||||
sample_lines = {
|
||||
"auth": [
|
||||
f"Jul 26 19:01:00 server sshd[1234]: Failed password for root from {attacker_ip} port 54321 ssh2",
|
||||
f"Jul 26 19:01:02 server sshd[1234]: Failed password for admin from {attacker_ip} port 54321 ssh2",
|
||||
f"Jul 26 19:01:04 server sshd[1234]: Failed password for ubuntu from {attacker_ip} port 54321 ssh2",
|
||||
f"Jul 26 19:01:06 server sshd[1234]: Failed password for user from {attacker_ip} port 54321 ssh2",
|
||||
f"Jul 26 19:01:08 server sshd[1234]: Failed password for deploy from {attacker_ip} port 54321 ssh2",
|
||||
f"Jul 26 19:01:10 server sshd[1234]: Failed password for git from {attacker_ip} port 54321 ssh2",
|
||||
f"Jul 26 19:01:12 server sshd[1234]: Invalid user hacker from {attacker_ip} port 54321",
|
||||
f"Jul 26 19:05:00 server sshd[5678]: Accepted password for deploy from {legit_ip} port 22122 ssh2",
|
||||
],
|
||||
"nginx": [
|
||||
f'185.220.100.42 - - [26/Jul/2026:19:01:00 +0300] "POST /api/v1/auth/login HTTP/1.1" 401 120',
|
||||
f'185.220.100.42 - - [26/Jul/2026:19:01:01 +0300] "POST /api/v1/auth/login HTTP/1.1" 401 120',
|
||||
f'185.220.100.42 - - [26/Jul/2026:19:01:02 +0300] "POST /api/v1/auth/login HTTP/1.1" 401 120',
|
||||
f'185.220.100.42 - - [26/Jul/2026:19:01:03 +0300] "GET /api/v1/users?id=1 UNION SELECT * FROM users-- HTTP/1.1" 400 80',
|
||||
f'192.168.1.10 - - [26/Jul/2026:19:02:00 +0300] "GET /dashboard HTTP/1.1" 200 4096',
|
||||
f'10.0.0.5 - - [26/Jul/2026:19:03:00 +0300] "GET /health HTTP/1.1" 200 32',
|
||||
],
|
||||
"app": [
|
||||
"[ERROR] Database connection timeout after 30s — retrying",
|
||||
"[CRITICAL] Unhandled exception in /api/v1/users/export: PermissionError",
|
||||
"[ERROR] JWT verification failed for token eyJ0eXAi... from IP 185.220.100.42",
|
||||
"[INFO] User admin@ghaymah.systems logged in from 192.168.1.10",
|
||||
"[ERROR] SQL query failed: syntax error near 'UNION'",
|
||||
],
|
||||
}
|
||||
return sample_lines
|
||||
|
||||
|
||||
def read_log_source(name: str, path: str) -> list[str]:
|
||||
"""Read lines from a log file, or fall back to sample data."""
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, "r", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
log.info(f"[{name}] Read {len(lines)} lines from {path}")
|
||||
return lines
|
||||
except PermissionError:
|
||||
log.warning(f"[{name}] Permission denied reading {path} — using sample data")
|
||||
else:
|
||||
log.warning(f"[{name}] File not found: {path} — using sample data")
|
||||
|
||||
# Return sample data
|
||||
return generate_sample_logs().get(name, [])
|
||||
|
||||
|
||||
def analyze_auth_log(lines: list[str]):
|
||||
"""Detect brute force SSH attempts."""
|
||||
for line in lines:
|
||||
m = PATTERNS["brute_force"].search(line)
|
||||
if m:
|
||||
ip = m.group("ip")
|
||||
failed_logins[ip] += 1
|
||||
|
||||
m = PATTERNS["invalid_user"].search(line)
|
||||
if m:
|
||||
ip = m.group("ip")
|
||||
failed_logins[ip] += 1
|
||||
|
||||
for ip, count in failed_logins.items():
|
||||
if count >= ALERT_THRESHOLD_FAIL:
|
||||
create_alert(
|
||||
source="auth.log",
|
||||
alert_type="brute_force_ssh",
|
||||
severity="CRITICAL",
|
||||
ip=ip,
|
||||
detail=f"{count} failed SSH login attempts detected",
|
||||
)
|
||||
|
||||
|
||||
def analyze_nginx_log(lines: list[str]):
|
||||
"""Detect HTTP flooding and SQL injection attempts."""
|
||||
ip_requests = defaultdict(list)
|
||||
|
||||
for line in lines:
|
||||
m = PATTERNS["nginx_request"].search(line)
|
||||
if m:
|
||||
ip = m.group("ip")
|
||||
path = m.group("path")
|
||||
status = m.group("status")
|
||||
ip_requests[ip].append((path, status))
|
||||
|
||||
# Check for SQL injection in path
|
||||
if PATTERNS["sql_injection"].search(path):
|
||||
create_alert(
|
||||
source="nginx/access.log",
|
||||
alert_type="sql_injection_attempt",
|
||||
severity="HIGH",
|
||||
ip=ip,
|
||||
detail=f"Possible SQL injection detected in request path: {path[:120]}",
|
||||
)
|
||||
|
||||
# Check high request rate
|
||||
for ip, reqs in ip_requests.items():
|
||||
failed = [r for r in reqs if r[1].startswith(("4", "5"))]
|
||||
if len(reqs) >= ALERT_THRESHOLD_RATE:
|
||||
create_alert(
|
||||
source="nginx/access.log",
|
||||
alert_type="http_flood",
|
||||
severity="HIGH",
|
||||
ip=ip,
|
||||
detail=f"{len(reqs)} requests detected in log window ({len(failed)} errors)",
|
||||
)
|
||||
elif len(failed) >= ALERT_THRESHOLD_FAIL:
|
||||
create_alert(
|
||||
source="nginx/access.log",
|
||||
alert_type="repeated_http_errors",
|
||||
severity="MEDIUM",
|
||||
ip=ip,
|
||||
detail=f"{len(failed)} HTTP error responses to IP in log window",
|
||||
)
|
||||
|
||||
|
||||
def analyze_app_log(lines: list[str]):
|
||||
"""Detect application-level errors and anomalies."""
|
||||
for line in lines:
|
||||
m = PATTERNS["app_error"].search(line)
|
||||
if m:
|
||||
level = m.group("level")
|
||||
msg = m.group("msg")
|
||||
|
||||
# Extract IP if present
|
||||
ip_match = re.search(r"\d+\.\d+\.\d+\.\d+", msg)
|
||||
ip = ip_match.group(0) if ip_match else "unknown"
|
||||
|
||||
if "JWT" in msg or "token" in msg.lower():
|
||||
severity = "HIGH"
|
||||
elif level == "CRITICAL":
|
||||
severity = "CRITICAL"
|
||||
else:
|
||||
severity = "MEDIUM"
|
||||
create_alert(
|
||||
source="app/application.log",
|
||||
alert_type="application_error",
|
||||
severity=severity,
|
||||
ip=ip,
|
||||
detail=msg[:200],
|
||||
)
|
||||
|
||||
|
||||
def create_alert(source: str, alert_type: str, severity: str, ip: str, detail: str):
|
||||
"""Create a deduplicated alert entry."""
|
||||
# Deduplicate: only merge if same type + IP + first 60 chars of detail match.
|
||||
# This prevents a low-severity alert from absorbing a different high-severity one.
|
||||
for existing in alerts:
|
||||
if (existing["type"] == alert_type
|
||||
and existing["ip"] == ip
|
||||
and existing["detail"][:60] == detail[:60]):
|
||||
existing["count"] += 1
|
||||
existing["last_seen"] = datetime.now().isoformat()
|
||||
return
|
||||
|
||||
alert_entry = {
|
||||
"id": len(alerts) + 1,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"last_seen": datetime.now().isoformat(),
|
||||
"source": source,
|
||||
"type": alert_type,
|
||||
"severity": severity,
|
||||
"ip": ip,
|
||||
"detail": detail,
|
||||
"count": 1,
|
||||
"status": "open",
|
||||
}
|
||||
alerts.append(alert_entry)
|
||||
log.warning(f"[ALERT] [{severity}] {alert_type} — IP: {ip} — {detail[:80]}")
|
||||
|
||||
|
||||
def run_scan():
|
||||
"""Main scan loop — reads all 3 log sources and runs analysis.
|
||||
|
||||
NOTE: This implementation is stateless — alert history resets on each
|
||||
scan cycle. This is acceptable for demo/project use. Production deployments
|
||||
should persist alert history in a database or SIEM backend (e.g.,
|
||||
Elasticsearch/OpenSearch) to retain trends and support correlation rules.
|
||||
"""
|
||||
global alerts, failed_logins
|
||||
alerts = []
|
||||
failed_logins = defaultdict(int)
|
||||
|
||||
log.info("Starting SIEM scan cycle...")
|
||||
|
||||
# Source 1: Auth logs
|
||||
auth_lines = read_log_source("auth", LOG_SOURCES["auth"])
|
||||
analyze_auth_log(auth_lines)
|
||||
|
||||
# Source 2: Nginx access logs
|
||||
nginx_lines = read_log_source("nginx", LOG_SOURCES["nginx"])
|
||||
analyze_nginx_log(nginx_lines)
|
||||
|
||||
# Source 3: Application logs
|
||||
app_lines = read_log_source("app", LOG_SOURCES["app"])
|
||||
analyze_app_log(app_lines)
|
||||
|
||||
# Sort by severity
|
||||
severity_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
|
||||
alerts.sort(key=lambda a: severity_order.get(a["severity"], 9))
|
||||
|
||||
output = {
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"total_alerts": len(alerts),
|
||||
"critical_count": sum(1 for a in alerts if a["severity"] == "CRITICAL"),
|
||||
"high_count": sum(1 for a in alerts if a["severity"] == "HIGH"),
|
||||
"medium_count": sum(1 for a in alerts if a["severity"] == "MEDIUM"),
|
||||
"alerts": alerts,
|
||||
}
|
||||
|
||||
with open(OUTPUT_FILE, "w") as f:
|
||||
json.dump(output, f, indent=2)
|
||||
|
||||
log.info(f"Scan complete. {len(alerts)} alerts written to {OUTPUT_FILE}")
|
||||
return output
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
log.info("=== Ghaymah SIEM Starting ===")
|
||||
log.info(f"Monitoring: {', '.join(LOG_SOURCES.values())}")
|
||||
log.info(f"Output: {OUTPUT_FILE}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
results = run_scan()
|
||||
print(f"\n[{datetime.now().strftime('%H:%M:%S')}] Alerts: {results['total_alerts']} "
|
||||
f"(CRITICAL: {results['critical_count']}, HIGH: {results['high_count']}, "
|
||||
f"MEDIUM: {results['medium_count']})")
|
||||
except KeyboardInterrupt:
|
||||
log.info("SIEM stopped by user.")
|
||||
break
|
||||
except Exception as e:
|
||||
log.error(f"Scan error: {e}")
|
||||
|
||||
time.sleep(SCAN_INTERVAL_SEC)
|
||||
المرجع في مشكلة جديدة
حظر مستخدم