"""Severity scoring (Chapter 10): a simple, transparent rank-escalation model — no ML, easy to explain to a non-expert user (Ch10's own requirement). Chapter 07 assigns a PROVISIONAL severity per rule type at parse time. This module computes an EFFECTIVE severity by escalating that base rank for repeated/rapid hits from the same IP — the exact rule Ch10 names. Escalation is rank-based and strictly non-decreasing: it starts at the base severity's rank and only ever moves up (repeat-count / hit-rate bonuses), clamped at "high". An earlier weighted-score-vs-fixed-threshold version could silently *downgrade* an isolated high-severity event (e.g. a single sqlmap hit) to "medium" purely because the thresholds weren't calibrated to the base weights — caught by running the pipeline against real sample data. A single dangerous event must never end up rated below its own base severity; only repetition/rate should push it higher. """ from __future__ import annotations from dataclasses import dataclass _SEVERITY_RANKS = ["low", "medium", "high"] _RANK_BY_SEVERITY = {name: rank for rank, name in enumerate(_SEVERITY_RANKS)} _REPEAT_COUNT_HIGH = 20 _REPEAT_COUNT_MEDIUM = 5 _RAPID_AVG_INTERVAL_SECONDS = 60 # >1 event/minute from one IP suggests automation @dataclass(frozen=True) class SeverityInputs: base_severity: str ip_event_count: int avg_interval_seconds: float | None # None if this IP has < 2 events in the window def compute_effective_severity(inputs: SeverityInputs) -> str: """Two named, inspectable escalation rules: repeat-count and hit-rate.""" rank = _RANK_BY_SEVERITY.get(inputs.base_severity, 0) if inputs.ip_event_count >= _REPEAT_COUNT_HIGH: rank += 2 elif inputs.ip_event_count >= _REPEAT_COUNT_MEDIUM: rank += 1 if inputs.avg_interval_seconds is not None and inputs.avg_interval_seconds < _RAPID_AVG_INTERVAL_SECONDS: rank += 1 rank = min(rank, len(_SEVERITY_RANKS) - 1) return _SEVERITY_RANKS[rank]