added repeat offenders monitoring
This commit is contained in:
@@ -25,6 +25,15 @@ JOURNAL_UNIT=mailserver
|
|||||||
# outside these ranges are flagged in the report as noteworthy.
|
# outside these ranges are flagged in the report as noteworthy.
|
||||||
TRUSTED_LOGIN_NETWORKS=10.0.0.0/24
|
TRUSTED_LOGIN_NETWORKS=10.0.0.0/24
|
||||||
|
|
||||||
|
# --- Multi-day repeat-offender tracking ---
|
||||||
|
# Requires the /data mount added to the systemd unit (bind-mounted, read-write)
|
||||||
|
# so this state survives across container restarts/daily runs.
|
||||||
|
STATE_FILE=/data/offender_state.json
|
||||||
|
# An IP gets called out as a repeat offender once seen on this many distinct days
|
||||||
|
REPEAT_OFFENDER_THRESHOLD_DAYS=3
|
||||||
|
# IPs not seen again within this many days are dropped from tracking
|
||||||
|
OFFENDER_RETENTION_DAYS=30
|
||||||
|
|
||||||
# --- Execution Options ---
|
# --- Execution Options ---
|
||||||
# Set to 'true' to see detailed background processes, 'false' for clean logs
|
# Set to 'true' to see detailed background processes, 'false' for clean logs
|
||||||
DEBUG=true
|
DEBUG=true
|
||||||
|
|||||||
115
daily_summary.py
115
daily_summary.py
@@ -1,10 +1,12 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import json
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import subprocess
|
import subprocess
|
||||||
import requests
|
import requests
|
||||||
import smtplib
|
import smtplib
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
|
from datetime import date, timedelta
|
||||||
import time
|
import time
|
||||||
import schedule
|
import schedule
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
@@ -37,6 +39,13 @@ TRUSTED_LOGIN_NETWORKS = [
|
|||||||
n.strip() for n in os.getenv("TRUSTED_LOGIN_NETWORKS", "10.0.0.0/24").split(",") if n.strip()
|
n.strip() for n in os.getenv("TRUSTED_LOGIN_NETWORKS", "10.0.0.0/24").split(",") if n.strip()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# --- Multi-day repeat-offender tracking (persisted on the /data mount) ---
|
||||||
|
STATE_FILE = os.getenv("STATE_FILE", "/data/offender_state.json")
|
||||||
|
# An IP is called out as a "repeat offender" once it's shown up on this many distinct days
|
||||||
|
REPEAT_OFFENDER_THRESHOLD_DAYS = int(os.getenv("REPEAT_OFFENDER_THRESHOLD_DAYS", 3))
|
||||||
|
# IPs not seen again after this many days are dropped from state, so the file doesn't grow forever
|
||||||
|
OFFENDER_RETENTION_DAYS = int(os.getenv("OFFENDER_RETENTION_DAYS", 30))
|
||||||
|
|
||||||
# --- EXECUTION TOGGLES ---
|
# --- EXECUTION TOGGLES ---
|
||||||
DEBUG = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes")
|
DEBUG = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes")
|
||||||
RUN_NOW = os.getenv("RUN_NOW", "false").lower() in ("true", "1", "yes")
|
RUN_NOW = os.getenv("RUN_NOW", "false").lower() in ("true", "1", "yes")
|
||||||
@@ -78,9 +87,9 @@ def _is_trusted(ip):
|
|||||||
|
|
||||||
# --- Regexes for structured extraction (mail server specific) ---
|
# --- Regexes for structured extraction (mail server specific) ---
|
||||||
CONNECT_RE = re.compile(r'postscreen\[\d+\]: CONNECT from \[([\d.:a-fA-F]+)\]')
|
CONNECT_RE = re.compile(r'postscreen\[\d+\]: CONNECT from \[([\d.:a-fA-F]+)\]')
|
||||||
REJECT_RE = re.compile(r'postscreen\[\d+\]: NOQUEUE: reject: RCPT from \[[^\]]+\]:\d+: (\d{3} \S+ [^;]+)')
|
REJECT_RE = re.compile(r'postscreen\[\d+\]: NOQUEUE: reject: RCPT from \[([\d.:a-fA-F]+)\]:\d+: (\d{3} \S+ [^;]+)')
|
||||||
DNSBL_RE = re.compile(r'dnsblog\[\d+\]: addr')
|
DNSBL_RE = re.compile(r'dnsblog\[\d+\]: addr')
|
||||||
RELAY_DENIED_RE = re.compile(r'reject: RCPT from \S+: 554 [\d.]+ <[^>]+>: Relay access denied.*from=<([^>]+)>')
|
RELAY_DENIED_RE = re.compile(r'reject: RCPT from \S+\[([\d.:a-fA-F]+)\]: 554 [\d.]+ <[^>]+>: Relay access denied.*from=<([^>]+)>')
|
||||||
LOGIN_RE = re.compile(r'imap-login: Login: user=<([^>]+)>, method=(\S+), rip=([\d.:a-fA-F]+)')
|
LOGIN_RE = re.compile(r'imap-login: Login: user=<([^>]+)>, method=(\S+), rip=([\d.:a-fA-F]+)')
|
||||||
AUTH_FAIL_RE = re.compile(r'auth failed|authentication failure|invalid password', re.IGNORECASE)
|
AUTH_FAIL_RE = re.compile(r'auth failed|authentication failure|invalid password', re.IGNORECASE)
|
||||||
SENT_RE = re.compile(r'status=sent')
|
SENT_RE = re.compile(r'status=sent')
|
||||||
@@ -100,8 +109,10 @@ def analyze_logs(lines):
|
|||||||
total_lines = len(lines)
|
total_lines = len(lines)
|
||||||
|
|
||||||
postscreen_rejects = Counter()
|
postscreen_rejects = Counter()
|
||||||
|
postscreen_reject_ips = Counter()
|
||||||
dnsbl_hits = 0
|
dnsbl_hits = 0
|
||||||
relay_denied = []
|
relay_denied = []
|
||||||
|
relay_denied_ips = Counter()
|
||||||
logins = defaultdict(list)
|
logins = defaultdict(list)
|
||||||
untrusted_logins = []
|
untrusted_logins = []
|
||||||
auth_failures = []
|
auth_failures = []
|
||||||
@@ -113,11 +124,13 @@ def analyze_logs(lines):
|
|||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
if m := REJECT_RE.search(line):
|
if m := REJECT_RE.search(line):
|
||||||
postscreen_rejects[m.group(1)] += 1
|
postscreen_reject_ips[m.group(1)] += 1
|
||||||
|
postscreen_rejects[m.group(2)] += 1
|
||||||
if DNSBL_RE.search(line):
|
if DNSBL_RE.search(line):
|
||||||
dnsbl_hits += 1
|
dnsbl_hits += 1
|
||||||
if m := RELAY_DENIED_RE.search(line):
|
if m := RELAY_DENIED_RE.search(line):
|
||||||
relay_denied.append(m.group(1))
|
relay_denied_ips[m.group(1)] += 1
|
||||||
|
relay_denied.append(m.group(2))
|
||||||
if m := LOGIN_RE.search(line):
|
if m := LOGIN_RE.search(line):
|
||||||
user, method, ip = m.group(1), m.group(2), m.group(3)
|
user, method, ip = m.group(1), m.group(2), m.group(3)
|
||||||
logins[user].append(ip)
|
logins[user].append(ip)
|
||||||
@@ -137,6 +150,20 @@ def analyze_logs(lines):
|
|||||||
elif m := GENERIC_WARNING_RE.search(line):
|
elif m := GENERIC_WARNING_RE.search(line):
|
||||||
other_warnings[m.group(1).strip()] += 1
|
other_warnings[m.group(1).strip()] += 1
|
||||||
|
|
||||||
|
# Union of IPs worth remembering across days: anything that got rejected,
|
||||||
|
# probed with junk protocol data, or tried to abuse the relay. Routine
|
||||||
|
# DNSBL-only connects are deliberately excluded -- too noisy/low-signal
|
||||||
|
# to track individually (see prior discussion on scanner noise).
|
||||||
|
suspicious_ips_today = Counter()
|
||||||
|
for ip, count in nonsmtp_by_ip.items():
|
||||||
|
suspicious_ips_today[ip] += count
|
||||||
|
for ip, count in postscreen_reject_ips.items():
|
||||||
|
suspicious_ips_today[ip] += count
|
||||||
|
for ip, count in relay_denied_ips.items():
|
||||||
|
suspicious_ips_today[ip] += count
|
||||||
|
for user, method, ip in untrusted_logins:
|
||||||
|
suspicious_ips_today[ip] += 1
|
||||||
|
|
||||||
facts = []
|
facts = []
|
||||||
facts.append(f"Total log lines: {total_lines}")
|
facts.append(f"Total log lines: {total_lines}")
|
||||||
facts.append(f"Postscreen automatic rejections (bots/scanners blocked before reaching mailbox): {sum(postscreen_rejects.values())}")
|
facts.append(f"Postscreen automatic rejections (bots/scanners blocked before reaching mailbox): {sum(postscreen_rejects.values())}")
|
||||||
@@ -178,7 +205,65 @@ def analyze_logs(lines):
|
|||||||
|
|
||||||
needs_attention = bool(auth_failures) or bool(untrusted_logins) or bool(relay_denied) or bool(other_warnings)
|
needs_attention = bool(auth_failures) or bool(untrusted_logins) or bool(relay_denied) or bool(other_warnings)
|
||||||
|
|
||||||
return total_lines, "\n".join(facts), needs_attention
|
return total_lines, "\n".join(facts), needs_attention, suspicious_ips_today
|
||||||
|
|
||||||
|
|
||||||
|
def load_offender_state():
|
||||||
|
if not os.path.exists(STATE_FILE):
|
||||||
|
debug_print(f"No existing state file at {STATE_FILE}, starting fresh.")
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(STATE_FILE, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
debug_print(f"Could not read state file ({e}), starting fresh.")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_offender_state(state):
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
|
||||||
|
with open(STATE_FILE, "w") as f:
|
||||||
|
json.dump(state, f, indent=2, sort_keys=True)
|
||||||
|
except OSError as e:
|
||||||
|
debug_print(f"Could not write state file: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def update_offender_state(state, suspicious_ips_today, report_date_str):
|
||||||
|
"""
|
||||||
|
Records which IPs were suspicious today, merges into the persisted
|
||||||
|
history, prunes IPs not seen recently, and returns (new_state, repeat_offenders).
|
||||||
|
repeat_offenders is a list of dicts sorted by distinct-day count descending.
|
||||||
|
"""
|
||||||
|
for ip, count in suspicious_ips_today.items():
|
||||||
|
entry = state.setdefault(ip, {"days": [], "hit_counts": {}})
|
||||||
|
if report_date_str not in entry["days"]:
|
||||||
|
entry["days"].append(report_date_str)
|
||||||
|
entry["hit_counts"][report_date_str] = entry["hit_counts"].get(report_date_str, 0) + count
|
||||||
|
entry["last_seen"] = report_date_str
|
||||||
|
|
||||||
|
# Prune anything not seen within the retention window
|
||||||
|
cutoff = date.fromisoformat(report_date_str) - timedelta(days=OFFENDER_RETENTION_DAYS)
|
||||||
|
pruned_state = {}
|
||||||
|
for ip, entry in state.items():
|
||||||
|
last_seen = date.fromisoformat(entry.get("last_seen", report_date_str))
|
||||||
|
if last_seen >= cutoff:
|
||||||
|
pruned_state[ip] = entry
|
||||||
|
|
||||||
|
repeat_offenders = []
|
||||||
|
for ip, entry in pruned_state.items():
|
||||||
|
distinct_days = len(entry["days"])
|
||||||
|
if distinct_days >= REPEAT_OFFENDER_THRESHOLD_DAYS:
|
||||||
|
repeat_offenders.append({
|
||||||
|
"ip": ip,
|
||||||
|
"distinct_days": distinct_days,
|
||||||
|
"first_seen": min(entry["days"]),
|
||||||
|
"last_seen": entry["last_seen"],
|
||||||
|
"total_hits": sum(entry["hit_counts"].values()),
|
||||||
|
})
|
||||||
|
repeat_offenders.sort(key=lambda x: x["distinct_days"], reverse=True)
|
||||||
|
|
||||||
|
return pruned_state, repeat_offenders
|
||||||
|
|
||||||
|
|
||||||
def get_ai_summary(facts_text):
|
def get_ai_summary(facts_text):
|
||||||
@@ -267,7 +352,25 @@ def send_email_report(total_traffic, ai_summary, facts_text, needs_attention):
|
|||||||
def job():
|
def job():
|
||||||
print(f"\n--- Starting log analysis for {JOURNAL_UNIT} ---")
|
print(f"\n--- Starting log analysis for {JOURNAL_UNIT} ---")
|
||||||
logs = get_yesterdays_logs()
|
logs = get_yesterdays_logs()
|
||||||
total_traffic, facts_text, needs_attention = analyze_logs(logs)
|
total_traffic, facts_text, needs_attention, suspicious_ips_today = analyze_logs(logs)
|
||||||
|
|
||||||
|
report_date_str = (date.today() - timedelta(days=1)).isoformat()
|
||||||
|
state = load_offender_state()
|
||||||
|
state, repeat_offenders = update_offender_state(state, suspicious_ips_today, report_date_str)
|
||||||
|
save_offender_state(state)
|
||||||
|
|
||||||
|
if repeat_offenders:
|
||||||
|
needs_attention = True
|
||||||
|
lines = [f"Repeat offenders (seen probing/rejected on {REPEAT_OFFENDER_THRESHOLD_DAYS}+ distinct days -- consider a firewall block):"]
|
||||||
|
for o in repeat_offenders:
|
||||||
|
lines.append(
|
||||||
|
f" - {o['ip']}: seen on {o['distinct_days']} distinct days "
|
||||||
|
f"(first {o['first_seen']}, last {o['last_seen']}), {o['total_hits']} total hits"
|
||||||
|
)
|
||||||
|
facts_text = facts_text + "\n" + "\n".join(lines)
|
||||||
|
else:
|
||||||
|
facts_text = facts_text + f"\nRepeat offenders (seen on {REPEAT_OFFENDER_THRESHOLD_DAYS}+ distinct days): 0"
|
||||||
|
|
||||||
debug_print(f"Extracted facts:\n{facts_text}")
|
debug_print(f"Extracted facts:\n{facts_text}")
|
||||||
summary = get_ai_summary(facts_text)
|
summary = get_ai_summary(facts_text)
|
||||||
send_email_report(total_traffic, summary, facts_text, needs_attention)
|
send_email_report(total_traffic, summary, facts_text, needs_attention)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ ExecStart=/usr/bin/env docker run \
|
|||||||
--log-driver=none \
|
--log-driver=none \
|
||||||
--network=host \
|
--network=host \
|
||||||
--env-file=/virt/daily-ai-summary/env \
|
--env-file=/virt/daily-ai-summary/env \
|
||||||
|
--mount type=bind,src=/virt/daily-ai-summary/data,dst=/data \
|
||||||
--mount type=bind,src=/var/log/journal,dst=/var/log/journal,ro \
|
--mount type=bind,src=/var/log/journal,dst=/var/log/journal,ro \
|
||||||
--mount type=bind,src=/run/log/journal,dst=/run/log/journal,ro \
|
--mount type=bind,src=/run/log/journal,dst=/run/log/journal,ro \
|
||||||
--mount type=bind,src=/etc/machine-id,dst=/etc/machine-id,ro \
|
--mount type=bind,src=/etc/machine-id,dst=/etc/machine-id,ro \
|
||||||
|
|||||||
Reference in New Issue
Block a user