sentrycove 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sentrycove/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """SentryCove: an open-source agent firewall and early-warning layer for web apps."""
2
+
3
+ from .alerts import Alert, AlertDispatcher, LogSink, WebhookSink
4
+ from .config import Config, load_config
5
+ from .engine import Engine, RequestInfo, Verdict
6
+ from .identity import AgentIdentity
7
+ from .middleware import SentryCove
8
+
9
+ __all__ = [
10
+ "SentryCove",
11
+ "AgentIdentity",
12
+ "Alert",
13
+ "AlertDispatcher",
14
+ "Config",
15
+ "Engine",
16
+ "RequestInfo",
17
+ "Verdict",
18
+ "LogSink",
19
+ "WebhookSink",
20
+ "load_config",
21
+ ]
22
+ __version__ = "0.2.1"
sentrycove/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """``python -m sentrycove``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
sentrycove/alerts.py ADDED
@@ -0,0 +1,165 @@
1
+ """Early-warning alerts: de-duplicated, non-blocking, routed by severity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import time
10
+ from collections.abc import Callable
11
+ from dataclasses import asdict, dataclass, field
12
+ from typing import Optional, Protocol
13
+
14
+ import httpx
15
+
16
+ from .config import AlertsConfig, SinkConfig
17
+ from .store import TTLCache
18
+
19
+ log = logging.getLogger("sentrycove.alerts")
20
+
21
+ SEVERITY_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3}
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Alert:
26
+ type: str
27
+ severity: str
28
+ title: str
29
+ detail: str = ""
30
+ client_ip: Optional[str] = None
31
+ method: Optional[str] = None
32
+ path: Optional[str] = None
33
+ agent: Optional[str] = None
34
+ action_taken: str = "none"
35
+ dedupe_key: str = ""
36
+ timestamp: float = field(default_factory=time.time)
37
+
38
+ def to_dict(self) -> dict:
39
+ return asdict(self)
40
+
41
+
42
+ class AlertSink(Protocol):
43
+ async def send(self, alert: Alert) -> None: ...
44
+
45
+ async def aclose(self) -> None: ...
46
+
47
+
48
+ class LogSink:
49
+ def __init__(self, logger: logging.Logger = log) -> None:
50
+ self._log = logger
51
+
52
+ async def send(self, alert: Alert) -> None:
53
+ level = logging.WARNING if SEVERITY_ORDER[alert.severity] >= 2 else logging.INFO
54
+ self._log.log(level, "sentrycove alert %s", json.dumps(alert.to_dict(), sort_keys=True))
55
+
56
+ async def aclose(self) -> None:
57
+ return None
58
+
59
+
60
+ def _slack_escape(text: str) -> str:
61
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
62
+
63
+
64
+ class WebhookSink:
65
+ """POSTs JSON. ``slack=True`` sends a Slack-compatible ``{"text": ...}`` body."""
66
+
67
+ def __init__(self, url: str, timeout: float, slack: bool = False) -> None:
68
+ self._url = url
69
+ self._timeout = timeout
70
+ self._slack = slack
71
+ self._client: Optional[httpx.AsyncClient] = None
72
+
73
+ def _payload(self, alert: Alert) -> dict:
74
+ if not self._slack:
75
+ return alert.to_dict()
76
+ lines = [
77
+ f"*[{alert.severity.upper()}] {_slack_escape(alert.title)}*",
78
+ _slack_escape(alert.detail) if alert.detail else "",
79
+ f"IP: `{_slack_escape(alert.client_ip or '-')}` Agent: `{_slack_escape(alert.agent or '-')}`",
80
+ f"Request: `{_slack_escape(alert.method or '-')} {_slack_escape(alert.path or '-')}`"
81
+ f" Action: `{alert.action_taken}`",
82
+ ]
83
+ return {"text": "\n".join(line for line in lines if line)}
84
+
85
+ async def send(self, alert: Alert) -> None:
86
+ if self._client is None:
87
+ self._client = httpx.AsyncClient(timeout=self._timeout, follow_redirects=False)
88
+ resp = await self._client.post(self._url, json=self._payload(alert))
89
+ if resp.status_code >= 400:
90
+ log.warning("alert webhook returned HTTP %s", resp.status_code)
91
+
92
+ async def aclose(self) -> None:
93
+ if self._client is not None:
94
+ await self._client.aclose()
95
+ self._client = None
96
+
97
+
98
+ def build_sink(cfg: SinkConfig, timeout: float) -> Optional[AlertSink]:
99
+ if cfg.type == "log":
100
+ return LogSink()
101
+ url = os.environ.get(cfg.url_env or "", "")
102
+ if not url:
103
+ log.warning("alert sink %s disabled: env var %s is not set", cfg.type, cfg.url_env)
104
+ return None
105
+ if not url.startswith("https://"):
106
+ log.warning("alert sink %s disabled: URL must use https", cfg.type)
107
+ return None
108
+ return WebhookSink(url, timeout, slack=cfg.type == "slack")
109
+
110
+
111
+ class AlertDispatcher:
112
+ def __init__(
113
+ self,
114
+ sinks: list[tuple[AlertSink, str]],
115
+ cooldown_seconds: float,
116
+ clock: Callable[[], float] = time.monotonic,
117
+ ) -> None:
118
+ self._sinks = sinks
119
+ self._cooldown = cooldown_seconds
120
+ self._recent: Optional[TTLCache[str, bool]] = (
121
+ TTLCache(50_000, cooldown_seconds, clock) if cooldown_seconds > 0 else None
122
+ )
123
+ self._tasks: set[asyncio.Task] = set()
124
+
125
+ @classmethod
126
+ def from_config(cls, cfg: AlertsConfig) -> AlertDispatcher:
127
+ sinks: list[tuple[AlertSink, str]] = []
128
+ for sink_cfg in cfg.sinks:
129
+ sink = build_sink(sink_cfg, cfg.timeout_seconds)
130
+ if sink is not None:
131
+ sinks.append((sink, sink_cfg.min_severity))
132
+ return cls(sinks, cfg.cooldown_seconds)
133
+
134
+ def emit(self, alert: Alert) -> bool:
135
+ """Queue an alert for delivery. Returns False if suppressed as a duplicate."""
136
+ key = alert.dedupe_key or f"{alert.type}|{alert.client_ip}|{alert.path}"
137
+ if self._recent is not None:
138
+ if key in self._recent:
139
+ return False
140
+ self._recent.set(key, True)
141
+ loop = asyncio.get_running_loop()
142
+ for sink, min_sev in self._sinks:
143
+ if SEVERITY_ORDER[alert.severity] < SEVERITY_ORDER[min_sev]:
144
+ continue
145
+ task = loop.create_task(self._deliver(sink, alert))
146
+ self._tasks.add(task)
147
+ task.add_done_callback(self._tasks.discard)
148
+ return True
149
+
150
+ @staticmethod
151
+ async def _deliver(sink: AlertSink, alert: Alert) -> None:
152
+ try:
153
+ await sink.send(alert)
154
+ except Exception as exc: # delivery problems must never affect traffic
155
+ log.warning("alert delivery via %s failed: %s", type(sink).__name__, exc)
156
+
157
+ async def drain(self) -> None:
158
+ """Wait for queued deliveries (useful in tests and on shutdown)."""
159
+ while self._tasks:
160
+ await asyncio.gather(*list(self._tasks), return_exceptions=True)
161
+
162
+ async def aclose(self) -> None:
163
+ await self.drain()
164
+ for sink, _ in self._sinks:
165
+ await sink.aclose()
sentrycove/cli.py ADDED
@@ -0,0 +1,127 @@
1
+ """Command line entry point: ``sentrycove proxy | sidecar | check-config``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import os
8
+ import sys
9
+ from collections.abc import Sequence
10
+ from typing import Optional
11
+
12
+ from . import __version__
13
+ from .config import load_config
14
+ from .engine import Engine
15
+
16
+ log = logging.getLogger("sentrycove")
17
+
18
+
19
+ def _env_int(name: str, default: int) -> int:
20
+ raw = os.environ.get(name)
21
+ if raw is None or raw == "":
22
+ return default
23
+ try:
24
+ return int(raw)
25
+ except ValueError:
26
+ raise SystemExit(f"{name} must be a number, got {raw!r}") from None
27
+
28
+
29
+ def build_parser() -> argparse.ArgumentParser:
30
+ parser = argparse.ArgumentParser(prog="sentrycove", description="Agent firewall and early-warning layer.")
31
+ parser.add_argument("--version", action="version", version=f"sentrycove {__version__}")
32
+ sub = parser.add_subparsers(dest="command", required=True)
33
+
34
+ def common(p: argparse.ArgumentParser, default_port: int) -> None:
35
+ p.add_argument("--config", default=os.environ.get("SENTRYCOVE_CONFIG"), help="policy YAML file")
36
+ p.add_argument("--host", default=os.environ.get("SENTRYCOVE_HOST", "127.0.0.1"), help="address to listen on")
37
+ p.add_argument("--port", type=int, default=_env_int("SENTRYCOVE_PORT", default_port))
38
+
39
+ proxy = sub.add_parser("proxy", help="reverse proxy in front of an app in any language")
40
+ common(proxy, 8080)
41
+ proxy.add_argument(
42
+ "--upstream", default=os.environ.get("SENTRYCOVE_UPSTREAM"), help="the app to protect, e.g. http://app:3000"
43
+ )
44
+ proxy.add_argument(
45
+ "--rewrite-host", action="store_true", help="send the upstream's own host name instead of the client's Host"
46
+ )
47
+
48
+ sidecar = sub.add_parser("sidecar", help="decision API for the Node and ASP.NET Core middleware")
49
+ common(sidecar, 8787)
50
+
51
+ check = sub.add_parser("check-config", help="validate a policy file and exit")
52
+ check.add_argument("path")
53
+ return parser
54
+
55
+
56
+ def _load_engine(config_path: Optional[str]) -> Engine:
57
+ if not config_path:
58
+ log.warning("no --config given: running with defaults (monitor mode, no rules, honeypots or login paths)")
59
+ engine = Engine(config_path or {})
60
+ log.info("policy loaded: mode=%s rules=%d", engine.config.mode, len(engine.config.rules))
61
+ return engine
62
+
63
+
64
+ def main(argv: Optional[Sequence[str]] = None) -> int:
65
+ args = build_parser().parse_args(argv)
66
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
67
+
68
+ if args.command == "check-config":
69
+ try:
70
+ cfg = load_config(args.path)
71
+ except Exception as exc:
72
+ print(f"invalid: {exc}", file=sys.stderr)
73
+ return 1
74
+ print(f"ok: mode={cfg.mode}, {len(cfg.rules)} rules, {len(cfg.honeypots.paths)} honeypot paths")
75
+ return 0
76
+
77
+ try:
78
+ import uvicorn
79
+ except ImportError:
80
+ print('this command needs the server extra: pip install "sentrycove[server]"', file=sys.stderr)
81
+ return 1
82
+
83
+ if args.command == "proxy":
84
+ if not args.upstream:
85
+ print("--upstream (or SENTRYCOVE_UPSTREAM) is required, e.g. http://127.0.0.1:3000", file=sys.stderr)
86
+ return 2
87
+ from .proxy import create_proxy_app
88
+
89
+ try:
90
+ app = create_proxy_app(args.upstream, engine=_load_engine(args.config), preserve_host=not args.rewrite_host)
91
+ except ValueError as exc:
92
+ print(f"error: {exc}", file=sys.stderr)
93
+ return 2
94
+ log.info("proxying %s:%d -> %s", args.host, args.port, args.upstream)
95
+ # proxy_headers=False: SentryCove resolves the client address itself (trusted_proxy_hops).
96
+ uvicorn.run(
97
+ app,
98
+ host=args.host,
99
+ port=args.port,
100
+ proxy_headers=False,
101
+ server_header=False,
102
+ date_header=False,
103
+ access_log=False,
104
+ log_config=None,
105
+ )
106
+ return 0
107
+
108
+ token = os.environ.get("SENTRYCOVE_API_TOKEN", "")
109
+ from .sidecar import MIN_TOKEN_LENGTH, DecisionAPI
110
+
111
+ if len(token) < MIN_TOKEN_LENGTH:
112
+ print(
113
+ f"SENTRYCOVE_API_TOKEN must be set to a secret of at least {MIN_TOKEN_LENGTH} characters "
114
+ '(generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))")',
115
+ file=sys.stderr,
116
+ )
117
+ return 2
118
+ api = DecisionAPI(_load_engine(args.config), token)
119
+ log.info("decision API listening on %s:%d", args.host, args.port)
120
+ uvicorn.run(
121
+ api, host=args.host, port=args.port, proxy_headers=False, server_header=False, access_log=False, log_config=None
122
+ )
123
+ return 0
124
+
125
+
126
+ if __name__ == "__main__":
127
+ sys.exit(main())
sentrycove/clientip.py ADDED
@@ -0,0 +1,43 @@
1
+ """Client IP resolution that cannot be spoofed via X-Forwarded-For.
2
+
3
+ Only the right-most ``trusted_proxy_hops`` entries of X-Forwarded-For are
4
+ written by proxies you control; anything to their left is client-supplied.
5
+ With ``trusted_proxy_hops: 0`` the header is ignored entirely.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ipaddress
11
+ from typing import Optional
12
+
13
+
14
+ def _valid_ip(value: str) -> Optional[str]:
15
+ try:
16
+ addr = ipaddress.ip_address(value.strip())
17
+ except ValueError:
18
+ return None
19
+ # "::ffff:203.0.113.9" (IPv4 seen through a dual-stack socket) is the same client as "203.0.113.9".
20
+ if addr.version == 6 and addr.ipv4_mapped is not None:
21
+ addr = addr.ipv4_mapped
22
+ return str(addr)
23
+
24
+
25
+ def resolve_client_ip(peer: Optional[str], xff_values: list[str], trusted_proxy_hops: int) -> str:
26
+ peer_ip = _valid_ip(peer) if peer else None
27
+ if trusted_proxy_hops > 0 and xff_values:
28
+ chain = [p.strip() for v in xff_values for p in v.split(",") if p.strip()]
29
+ if len(chain) >= trusted_proxy_hops:
30
+ candidate = _valid_ip(chain[-trusted_proxy_hops])
31
+ if candidate:
32
+ return candidate
33
+ return peer_ip or "unknown"
34
+
35
+
36
+ def network_prefix(ip: str) -> str:
37
+ """Coarse network bucket: /24 for IPv4, /48 for IPv6."""
38
+ try:
39
+ addr = ipaddress.ip_address(ip)
40
+ except ValueError:
41
+ return ip
42
+ bits = 24 if addr.version == 4 else 48
43
+ return str(ipaddress.ip_network(f"{addr}/{bits}", strict=False))
sentrycove/config.py ADDED
@@ -0,0 +1,187 @@
1
+ """Configuration model, loaded from YAML.
2
+
3
+ Unknown keys are rejected so that a typo in a policy file fails loudly
4
+ at startup instead of silently disabling a protection.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from pathlib import Path
11
+ from typing import Literal, Optional, Union
12
+
13
+ import yaml
14
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
15
+
16
+ Action = Literal["allow", "monitor", "block"]
17
+ Severity = Literal["low", "medium", "high", "critical"]
18
+
19
+ AGENT_KINDS = ("verified", "declared", "automation", "unclassified")
20
+ _SELECTOR_RE = re.compile(r"^(\*|(verified|declared|automation|unclassified)(:[A-Za-z0-9_.\-]+)?)$")
21
+
22
+
23
+ class _Strict(BaseModel):
24
+ model_config = ConfigDict(extra="forbid", frozen=True)
25
+
26
+
27
+ class TrustedAgent(_Strict):
28
+ """A signer whose key directory we are willing to fetch."""
29
+
30
+ name: str = Field(pattern=r"^[A-Za-z0-9_.\-]+$")
31
+ signature_agent: str = Field(description="Origin, e.g. https://agent.example")
32
+
33
+ @field_validator("signature_agent")
34
+ @classmethod
35
+ def _https_origin(cls, v: str) -> str:
36
+ v = v.rstrip("/")
37
+ if not re.fullmatch(r"https://[A-Za-z0-9.\-]+(:[0-9]{1,5})?", v):
38
+ raise ValueError("signature_agent must be an https origin with no path")
39
+ return v.lower()
40
+
41
+
42
+ class WebBotAuthConfig(_Strict):
43
+ enabled: bool = True
44
+ trusted_agents: list[TrustedAgent] = Field(default_factory=list)
45
+ max_signature_age_seconds: int = Field(default=300, gt=0)
46
+ max_validity_seconds: int = Field(default=86400, gt=0)
47
+ clock_skew_seconds: int = Field(default=30, ge=0)
48
+ directory_cache_seconds: int = Field(default=3600, gt=0)
49
+ directory_fetch_timeout_seconds: float = Field(default=2.0, gt=0)
50
+ require_nonce: bool = False
51
+
52
+
53
+ class Rule(_Strict):
54
+ name: str
55
+ paths: list[str] = Field(default_factory=lambda: ["*"])
56
+ methods: list[str] = Field(default_factory=lambda: ["*"])
57
+ agents: list[str] = Field(default_factory=lambda: ["*"])
58
+ action: Action
59
+ severity: Severity = "medium"
60
+
61
+ @field_validator("methods")
62
+ @classmethod
63
+ def _upper(cls, v: list[str]) -> list[str]:
64
+ return [m.upper() for m in v]
65
+
66
+ @field_validator("agents")
67
+ @classmethod
68
+ def _selectors(cls, v: list[str]) -> list[str]:
69
+ for sel in v:
70
+ if not _SELECTOR_RE.match(sel):
71
+ raise ValueError(
72
+ f"invalid agent selector {sel!r}; use '*', a kind ({', '.join(AGENT_KINDS)}) or 'kind:name'"
73
+ )
74
+ return v
75
+
76
+
77
+ class HoneypotConfig(_Strict):
78
+ enabled: bool = True
79
+ paths: list[str] = Field(default_factory=list)
80
+ ban_seconds: int = Field(default=3600, ge=0)
81
+ response_status: int = Field(default=404, ge=400, le=599)
82
+
83
+
84
+ class LoginConfig(_Strict):
85
+ enabled: bool = True
86
+ paths: list[str] = Field(default_factory=list)
87
+ failure_statuses: list[int] = Field(default_factory=lambda: [401, 403])
88
+ per_ip_threshold: int = Field(default=10, gt=0)
89
+ global_threshold: int = Field(default=200, gt=0)
90
+ window_seconds: int = Field(default=60, gt=0)
91
+
92
+
93
+ class SessionConfig(_Strict):
94
+ enabled: bool = True
95
+ cookie_names: list[str] = Field(default_factory=list)
96
+ ttl_seconds: int = Field(default=86400, gt=0)
97
+ alert_on_ip_change: bool = False
98
+
99
+
100
+ class SinkConfig(_Strict):
101
+ type: Literal["log", "webhook", "slack"]
102
+ url_env: Optional[str] = Field(
103
+ default=None, description="Name of the env var holding the URL (never the URL itself)"
104
+ )
105
+ min_severity: Severity = "medium"
106
+
107
+ @model_validator(mode="after")
108
+ def _needs_url(self) -> SinkConfig:
109
+ if self.type in ("webhook", "slack") and not self.url_env:
110
+ raise ValueError(f"{self.type} sink requires url_env")
111
+ return self
112
+
113
+
114
+ class AlertsConfig(_Strict):
115
+ sinks: list[SinkConfig] = Field(default_factory=lambda: [SinkConfig(type="log", min_severity="low")])
116
+ cooldown_seconds: int = Field(default=300, ge=0)
117
+ timeout_seconds: float = Field(default=3.0, gt=0)
118
+
119
+
120
+ class LimitsConfig(_Strict):
121
+ max_tracked_keys: int = Field(default=100_000, gt=0)
122
+
123
+
124
+ class Config(_Strict):
125
+ version: Literal[1] = 1
126
+ mode: Literal["monitor", "enforce"] = "monitor"
127
+ fail_open: bool = True
128
+ trusted_proxy_hops: int = Field(default=0, ge=0, le=10)
129
+ declared_bot_tokens: list[str] = Field(
130
+ default_factory=lambda: [
131
+ "GPTBot",
132
+ "ChatGPT-User",
133
+ "OAI-SearchBot",
134
+ "ClaudeBot",
135
+ "Claude-User",
136
+ "Claude-SearchBot",
137
+ "PerplexityBot",
138
+ "Perplexity-User",
139
+ "Google-Extended",
140
+ "Googlebot",
141
+ "Bingbot",
142
+ "Meta-ExternalAgent",
143
+ "Meta-WebIndexer",
144
+ "Bytespider",
145
+ "CCBot",
146
+ "Amazonbot",
147
+ "Applebot",
148
+ ]
149
+ )
150
+ automation_tokens: list[str] = Field(
151
+ default_factory=lambda: [
152
+ "python-requests",
153
+ "python-httpx",
154
+ "aiohttp",
155
+ "curl/",
156
+ "wget/",
157
+ "go-http-client",
158
+ "okhttp",
159
+ "java/",
160
+ "libwww-perl",
161
+ "scrapy",
162
+ "headlesschrome",
163
+ "phantomjs",
164
+ "puppeteer",
165
+ "playwright",
166
+ "selenium",
167
+ ]
168
+ )
169
+ web_bot_auth: WebBotAuthConfig = Field(default_factory=WebBotAuthConfig)
170
+ rules: list[Rule] = Field(default_factory=list)
171
+ honeypots: HoneypotConfig = Field(default_factory=HoneypotConfig)
172
+ login: LoginConfig = Field(default_factory=LoginConfig)
173
+ session: SessionConfig = Field(default_factory=SessionConfig)
174
+ alerts: AlertsConfig = Field(default_factory=AlertsConfig)
175
+ limits: LimitsConfig = Field(default_factory=LimitsConfig)
176
+
177
+
178
+ def load_config(source: Union[str, Path, dict]) -> Config:
179
+ """Load configuration from a YAML file path or an already-parsed dict."""
180
+ if isinstance(source, dict):
181
+ data = source
182
+ else:
183
+ with open(source, encoding="utf-8") as fh:
184
+ data = yaml.safe_load(fh) or {}
185
+ if not isinstance(data, dict):
186
+ raise ValueError("configuration root must be a mapping")
187
+ return Config.model_validate(data)
@@ -0,0 +1,133 @@
1
+ """Detectors that produce early warnings without any change to the app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from dataclasses import dataclass
7
+ from typing import Optional
8
+
9
+ from .clientip import network_prefix
10
+ from .config import HoneypotConfig, LoginConfig, SessionConfig
11
+ from .paths import path_matches
12
+ from .store import SlidingWindowCounter, TTLCache
13
+
14
+
15
+ class HoneypotDetector:
16
+ """Decoy paths no legitimate user ever requests (e.g. /.env, /backup.zip)."""
17
+
18
+ def __init__(self, cfg: HoneypotConfig) -> None:
19
+ self._cfg = cfg
20
+
21
+ def is_trap(self, path: str) -> bool:
22
+ return self._cfg.enabled and path_matches(path, self._cfg.paths)
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class LoginSignal:
27
+ scope: str # "ip" | "global"
28
+ count: int
29
+ window_seconds: int
30
+
31
+
32
+ class LoginMonitor:
33
+ """Detects credential stuffing / brute force from failed-login responses."""
34
+
35
+ def __init__(self, cfg: LoginConfig, max_keys: int) -> None:
36
+ self._cfg = cfg
37
+ self._per_ip: SlidingWindowCounter[str] = SlidingWindowCounter(
38
+ cfg.window_seconds, max_keys, cfg.per_ip_threshold
39
+ )
40
+ self._global: SlidingWindowCounter[str] = SlidingWindowCounter(cfg.window_seconds, 1, cfg.global_threshold)
41
+
42
+ def is_login_path(self, path: str) -> bool:
43
+ return self._cfg.enabled and path_matches(path, self._cfg.paths)
44
+
45
+ def is_failure(self, status: int) -> bool:
46
+ return status in self._cfg.failure_statuses
47
+
48
+ def record_failure(self, client_ip: Optional[str]) -> list[LoginSignal]:
49
+ """Count one failed login. Pass ``None`` when the client address is unknown:
50
+ the failure then counts only towards the site-wide total."""
51
+ signals = []
52
+ if client_ip is not None:
53
+ ip_count = self._per_ip.hit(client_ip)
54
+ if ip_count >= self._cfg.per_ip_threshold:
55
+ signals.append(LoginSignal("ip", ip_count, self._cfg.window_seconds))
56
+ global_count = self._global.hit("all")
57
+ if global_count >= self._cfg.global_threshold:
58
+ signals.append(LoginSignal("global", global_count, self._cfg.window_seconds))
59
+ return signals
60
+
61
+
62
+ def parse_cookies(header_values: list[str]) -> dict[str, str]:
63
+ cookies: dict[str, str] = {}
64
+ for header in header_values:
65
+ for part in header.split(";"):
66
+ name, sep, value = part.strip().partition("=")
67
+ if sep and name and name not in cookies:
68
+ cookies[name] = value.strip().strip('"')
69
+ return cookies
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class _Fingerprint:
74
+ user_agent: str
75
+ accept_language: str
76
+ net: str
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class SessionSignal:
81
+ kind: str # "device_change" | "network_change"
82
+ severity: str
83
+ detail: str
84
+ session_hash: str
85
+
86
+
87
+ class SessionMonitor:
88
+ """Flags a session token being used from a different device/network.
89
+
90
+ The first fingerprint seen for a session is kept (an attacker replaying a
91
+ stolen cookie cannot overwrite it). Session IDs are stored only as
92
+ SHA-256 hashes.
93
+ """
94
+
95
+ def __init__(self, cfg: SessionConfig, max_keys: int) -> None:
96
+ self._cfg = cfg
97
+ self._seen: TTLCache[str, _Fingerprint] = TTLCache(max_keys, cfg.ttl_seconds)
98
+
99
+ def observe(
100
+ self,
101
+ cookie_headers: list[str],
102
+ client_ip: str,
103
+ user_agent: str,
104
+ accept_language: str,
105
+ ) -> Optional[SessionSignal]:
106
+ if not self._cfg.enabled or not self._cfg.cookie_names:
107
+ return None
108
+ cookies = parse_cookies(cookie_headers)
109
+ token = next((cookies[n] for n in self._cfg.cookie_names if cookies.get(n)), None)
110
+ if token is None:
111
+ return None
112
+ session_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
113
+ current = _Fingerprint(user_agent, accept_language, network_prefix(client_ip))
114
+ known = self._seen.get(session_hash)
115
+ if known is None:
116
+ self._seen.set(session_hash, current)
117
+ return None
118
+ if known.user_agent != current.user_agent or known.accept_language != current.accept_language:
119
+ return SessionSignal(
120
+ "device_change",
121
+ "high",
122
+ f"session first seen from network {known.net}, now used by a different "
123
+ f"browser/device from {current.net}",
124
+ session_hash,
125
+ )
126
+ if known.net != current.net and self._cfg.alert_on_ip_change:
127
+ return SessionSignal(
128
+ "network_change",
129
+ "low",
130
+ f"session moved from network {known.net} to {current.net}",
131
+ session_hash,
132
+ )
133
+ return None