Restructure sources into app package with unified entry point and update documentation

This commit is contained in:
zwitschi committed 2026-08-04 22:23:59 +02:00
1 parent 540154d426
commit f78faf8f8a
18 files changed
+167 -129

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Data models for the intrusion-detection system.
`Request` and `IPStats` plus the `aggregate()` fold function.
"""
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass, field
from typing import Iterable
from . import bad_patterns
from .ip_utils import ip_version, whitelist_of
@dataclass(slots=True)
class Request:
"""One normalised request line, produced by any log parser."""
ts: str = ""
ip: str = ""
method: str | None = None
path: str | None = None
status: int | None = None
upstream_status: int | None = None
host: str | None = None
user: str | None = None
ua: str | None = None
referer: str | None = None
denied: bool = False
banned: bool = False
extra: dict = field(default_factory=dict)
@property
def is_client_error(self) -> bool:
return self.status is not None and 400 <= self.status < 500
@property
def is_server_error(self) -> bool:
return self.status is not None and 500 <= self.status < 600
@property
def is_error(self) -> bool:
return self.denied or self.is_client_error or self.is_server_error
@dataclass
class IPStats:
"""Aggregated per-IP counters."""
ip: str
version: int = 0
total: int = 0
errors: int = 0
c4xx: int = 0
c5xx: int = 0
denied: int = 0
banned: int = 0
first: str = ""
last: str = ""
paths: Counter = field(default_factory=Counter)
methods: Counter = field(default_factory=Counter)
hosts: Counter = field(default_factory=Counter)
uas: Counter = field(default_factory=Counter)
users: set[str] = field(default_factory=set)
malicious_hits: int = 0
malicious_patterns: dict[str, int] = field(default_factory=dict)
@property
def error_ratio(self) -> float:
return round(self.errors / self.total, 3) if self.total else 0.0
@property
def authenticated(self) -> bool:
"""True if the IP ever presented a real (non-anonymous) user."""
return any(u and u != "*" for u in self.users)
@property
def whitelist(self) -> str:
return whitelist_of(self.ip) or ""
@property
def users_str(self) -> str:
return ",".join(sorted(self.users)) or "-"
@property
def methods_str(self) -> str:
return ",".join(sorted(self.methods)) or "-"
def to_dict(self) -> dict:
return {
"ip": self.ip,
"version": self.version,
"total": self.total,
"errors": self.errors,
"error_ratio": self.error_ratio,
"status_4xx": self.c4xx,
"status_5xx": self.c5xx,
"denied": self.denied,
"banned": self.banned,
"first": self.first,
"last": self.last,
"whitelist": self.whitelist or None,
"users": sorted(self.users),
"methods": dict(self.methods),
"hosts": dict(self.hosts),
"paths": dict(self.paths.most_common()),
"uas": dict(self.uas.most_common(10)),
"malicious_hits": self.malicious_hits,
"malicious_patterns": dict(self.malicious_patterns),
}
def aggregate(requests: Iterable[Request]) -> dict[str, IPStats]:
"""Fold request records into per-IP statistics."""
stats: dict[str, IPStats] = {}
for r in requests:
s = stats.get(r.ip)
if s is None:
s = stats[r.ip] = IPStats(
ip=r.ip, version=ip_version(r.ip) or 0, first=r.ts)
s.total += 1
s.last = r.ts
if r.denied:
s.denied += 1
if r.banned:
s.banned += 1
if r.is_client_error:
s.c4xx += 1
if r.is_server_error:
s.c5xx += 1
if r.is_error:
s.errors += 1
if r.path:
s.paths[r.path] += 1
if r.method:
s.methods[r.method] += 1
if r.host:
s.hosts[r.host] += 1
if r.ua and r.ua != "-":
s.uas[r.ua] += 1
if r.user:
s.users.add(r.user)
if r.path and bad_patterns.is_malicious(r.path):
s.malicious_hits += 1
for p in bad_patterns.classify_path(r.path):
s.malicious_patterns[p.name] = s.malicious_patterns.get(
p.name, 0) + 1
return stats