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 +22 -0
- sentrycove/__main__.py +7 -0
- sentrycove/alerts.py +165 -0
- sentrycove/cli.py +127 -0
- sentrycove/clientip.py +43 -0
- sentrycove/config.py +187 -0
- sentrycove/detectors.py +133 -0
- sentrycove/engine.py +328 -0
- sentrycove/identity.py +64 -0
- sentrycove/middleware.py +114 -0
- sentrycove/paths.py +57 -0
- sentrycove/policy.py +33 -0
- sentrycove/proxy.py +291 -0
- sentrycove/sfv.py +274 -0
- sentrycove/sidecar.py +178 -0
- sentrycove/store.py +92 -0
- sentrycove/webbotauth.py +400 -0
- sentrycove-0.2.1.dist-info/METADATA +238 -0
- sentrycove-0.2.1.dist-info/RECORD +22 -0
- sentrycove-0.2.1.dist-info/WHEEL +4 -0
- sentrycove-0.2.1.dist-info/entry_points.txt +2 -0
- sentrycove-0.2.1.dist-info/licenses/LICENSE +202 -0
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
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("&", "&").replace("<", "<").replace(">", ">")
|
|
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)
|
sentrycove/detectors.py
ADDED
|
@@ -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
|