analytics-platform 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.
- analytics_platform/__init__.py +483 -0
- analytics_platform/analytics.py +101 -0
- analytics_platform/audit.py +53 -0
- analytics_platform/core.py +199 -0
- analytics_platform/crawlers.py +191 -0
- analytics_platform/django.py +112 -0
- analytics_platform/errors.py +113 -0
- analytics_platform/firewall.py +315 -0
- analytics_platform/flags.py +46 -0
- analytics_platform/forms.py +58 -0
- analytics_platform/inventory.py +72 -0
- analytics_platform/maintenance.py +61 -0
- analytics_platform/performance.py +98 -0
- analytics_platform/posture.py +264 -0
- analytics_platform/proxy.py +170 -0
- analytics_platform/request_rules.py +175 -0
- analytics_platform/resources.py +471 -0
- analytics_platform-0.2.1.dist-info/METADATA +227 -0
- analytics_platform-0.2.1.dist-info/RECORD +22 -0
- analytics_platform-0.2.1.dist-info/WHEEL +5 -0
- analytics_platform-0.2.1.dist-info/licenses/LICENSE +21 -0
- analytics_platform-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Firewall module: rules from the collector (GET /events/firewall/rules,
|
|
2
|
+
protocol in sdk/protocols/firewall.md) every minute or so; every request is
|
|
3
|
+
checked against them in memory, so the app never waits on the collector.
|
|
4
|
+
What was blocked (or, in monitor mode, would have been) goes back in batches.
|
|
5
|
+
|
|
6
|
+
Fails open: until rules arrive, and whenever the collector can't be reached,
|
|
7
|
+
requests pass (with the last rules that arrived, if any). The rate limit
|
|
8
|
+
counts in this process: with several workers, pass `counter` (e.g. Redis)
|
|
9
|
+
for one count across them.
|
|
10
|
+
"""
|
|
11
|
+
import bisect
|
|
12
|
+
import ipaddress
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
|
|
19
|
+
from .core import Periodic, RETRY, header
|
|
20
|
+
from .request_rules import compile_rules, match_request
|
|
21
|
+
|
|
22
|
+
PROTOCOL = 1
|
|
23
|
+
MIN_SYNC = 30
|
|
24
|
+
FLUSH_EVERY = 5
|
|
25
|
+
BATCH = 200
|
|
26
|
+
QUEUE_MAX = 2000
|
|
27
|
+
COUNTER_MAX = 100000
|
|
28
|
+
MAPPED = 0xFFFF << 32 # IPv4 as ::ffff:a.b.c.d, so one sorted list holds both
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ip_key(text):
|
|
32
|
+
"""An address as an int in IPv6 space, or None when invalid."""
|
|
33
|
+
try:
|
|
34
|
+
ip = ipaddress.ip_address(str(text).strip().split("%")[0])
|
|
35
|
+
except ValueError:
|
|
36
|
+
return None
|
|
37
|
+
if ip.version == 4:
|
|
38
|
+
return MAPPED | int(ip)
|
|
39
|
+
return int(ip)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_range(entry):
|
|
43
|
+
"""'1.2.3.0/24' -> (start, end) as ip_key()s, or None."""
|
|
44
|
+
try:
|
|
45
|
+
net = ipaddress.ip_network(str(entry).strip(), strict=False)
|
|
46
|
+
except ValueError:
|
|
47
|
+
return None
|
|
48
|
+
start, end = int(net.network_address), int(net.broadcast_address)
|
|
49
|
+
if net.version == 4:
|
|
50
|
+
return MAPPED | start, MAPPED | end
|
|
51
|
+
return start, end
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class IpSet:
|
|
55
|
+
"""Addresses and ranges, merged and sorted, looked up by binary search."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, entries=()):
|
|
58
|
+
ranges = sorted(r for r in (parse_range(e) for e in entries if isinstance(e, str)) if r)
|
|
59
|
+
self.starts, self.ends = [], []
|
|
60
|
+
for start, end in ranges:
|
|
61
|
+
if self.ends and start <= self.ends[-1] + 1:
|
|
62
|
+
self.ends[-1] = max(self.ends[-1], end)
|
|
63
|
+
else:
|
|
64
|
+
self.starts.append(start)
|
|
65
|
+
self.ends.append(end)
|
|
66
|
+
|
|
67
|
+
def has(self, key):
|
|
68
|
+
i = bisect.bisect_right(self.starts, key) - 1
|
|
69
|
+
return i >= 0 and self.ends[i] >= key
|
|
70
|
+
|
|
71
|
+
def __len__(self):
|
|
72
|
+
return len(self.starts)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def compile_probes(probes):
|
|
76
|
+
"""The rules' probes (addresses of files no visitor asks for: .env,
|
|
77
|
+
.git/...) -> {"exact": set, "prefix": tuple, "ban": seconds}, or None."""
|
|
78
|
+
if not isinstance(probes, dict):
|
|
79
|
+
return None
|
|
80
|
+
def paths(key):
|
|
81
|
+
return [p.lower() for p in probes.get(key) or [] if isinstance(p, str) and p]
|
|
82
|
+
exact, prefix = set(paths("exact")), tuple(paths("prefix"))
|
|
83
|
+
if not exact and not prefix:
|
|
84
|
+
return None
|
|
85
|
+
try:
|
|
86
|
+
ban = max(0, int(probes.get("ban") or 0))
|
|
87
|
+
except (TypeError, ValueError):
|
|
88
|
+
ban = 0
|
|
89
|
+
return {"exact": exact, "prefix": prefix, "ban": ban}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def is_probe(path, probes):
|
|
93
|
+
"""True when the path (no query, lower-cased, "//" as "/") is a probe."""
|
|
94
|
+
if not probes:
|
|
95
|
+
return False
|
|
96
|
+
clean = re.sub(r"/{2,}", "/", re.split(r"[?#]", str(path or ""), 1)[0].lower())
|
|
97
|
+
return clean in probes["exact"] or (bool(probes["prefix"]) and clean.startswith(probes["prefix"]))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def client_ip(remote, headers, trust_proxy):
|
|
101
|
+
"""The visitor's address. trust_proxy: None/0 the connection's address; a
|
|
102
|
+
number of proxies in X-Forwarded-For (True = 1); "cloudflare" reads
|
|
103
|
+
CF-Connecting-IP; a function gets the headers and returns it."""
|
|
104
|
+
if callable(trust_proxy):
|
|
105
|
+
return str(trust_proxy(headers) or "")
|
|
106
|
+
if trust_proxy == "cloudflare":
|
|
107
|
+
return header(headers, "cf-connecting-ip") or remote
|
|
108
|
+
hops = 1 if trust_proxy is True else (trust_proxy if isinstance(trust_proxy, int) else 0)
|
|
109
|
+
if hops > 0:
|
|
110
|
+
chain = [s.strip() for s in header(headers, "x-forwarded-for").split(",") if s.strip()]
|
|
111
|
+
if chain:
|
|
112
|
+
return chain[max(0, len(chain) - hops)]
|
|
113
|
+
return remote
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Firewall:
|
|
117
|
+
"""Options: trust_proxy, mode ("monitor"/"block": overrides the dashboard),
|
|
118
|
+
counter (callable(key, ttl) -> hits, shared between workers), cache_file
|
|
119
|
+
(rules kept there between restarts)."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, core, trust_proxy=None, mode=None, counter=None, cache_file=None):
|
|
122
|
+
self.core = core
|
|
123
|
+
self.trust_proxy = trust_proxy
|
|
124
|
+
self.mode = mode if mode in ("monitor", "block") else None
|
|
125
|
+
self.counter = counter
|
|
126
|
+
self.cache_file = cache_file
|
|
127
|
+
self.rules = None
|
|
128
|
+
self.etag = ""
|
|
129
|
+
self.lock = threading.Lock()
|
|
130
|
+
self.counts = {}
|
|
131
|
+
self.queue = {}
|
|
132
|
+
# Bot protection here: address -> when its ban ends; address -> [window start, failed logins].
|
|
133
|
+
self.bans = {}
|
|
134
|
+
self.logins = {}
|
|
135
|
+
if cache_file:
|
|
136
|
+
try:
|
|
137
|
+
with open(cache_file) as f:
|
|
138
|
+
cached = json.load(f)
|
|
139
|
+
self.rules = self._compile(cached["rules"])
|
|
140
|
+
self.etag = cached.get("etag", "")
|
|
141
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
142
|
+
pass
|
|
143
|
+
self.jobs = []
|
|
144
|
+
if core.valid:
|
|
145
|
+
self.jobs = [Periodic(self._sync), Periodic(self._flush_job, FLUSH_EVERY)]
|
|
146
|
+
|
|
147
|
+
def _compile(self, data):
|
|
148
|
+
if not isinstance(data, dict) or data.get("v") != PROTOCOL:
|
|
149
|
+
raise ValueError("unknown rules version")
|
|
150
|
+
rate = data.get("rate") or None
|
|
151
|
+
if rate and int(rate.get("limit") or 0) > 0:
|
|
152
|
+
rate = {"limit": int(rate["limit"]), "window": max(1, int(rate.get("window") or 60))}
|
|
153
|
+
else:
|
|
154
|
+
rate = None
|
|
155
|
+
return {
|
|
156
|
+
"enabled": data.get("enabled") is True,
|
|
157
|
+
"mode": "block" if data.get("mode") == "block" else "monitor",
|
|
158
|
+
"sync": max(MIN_SYNC, int(data.get("sync") or RETRY)),
|
|
159
|
+
"allow": IpSet(data.get("allow") or []),
|
|
160
|
+
# Crawlers the rate limit must not catch (absent in older rules).
|
|
161
|
+
"goodbots": IpSet(data.get("goodbots") or []),
|
|
162
|
+
"deny": IpSet(data.get("deny") or []),
|
|
163
|
+
"list": IpSet(data.get("lists") or []),
|
|
164
|
+
"shared": IpSet(data.get("shared") or []),
|
|
165
|
+
"rate": rate,
|
|
166
|
+
"banned": IpSet(data.get("banned") or []),
|
|
167
|
+
"ban": int((data.get("ban") or {}).get("seconds") or 0),
|
|
168
|
+
"login": data.get("login") if isinstance(data.get("login"), dict) and int(data["login"].get("limit") or 0) > 0 else None,
|
|
169
|
+
"probes": compile_probes(data.get("probes")),
|
|
170
|
+
# Virtual patching: rules that match the request, not the address.
|
|
171
|
+
"requests": compile_rules(data.get("requests")),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
def _sync(self):
|
|
175
|
+
headers = {"If-None-Match": self.etag} if self.etag else {}
|
|
176
|
+
res = self.core.send("firewall", "/events/firewall/rules?site_id=" + self.core.site_id, method="GET", auth="bearer", headers=headers)
|
|
177
|
+
if res is not None and res.status == 200:
|
|
178
|
+
data = res.json()
|
|
179
|
+
try:
|
|
180
|
+
self.rules = self._compile(data)
|
|
181
|
+
self.etag = res.headers.get("etag", "")
|
|
182
|
+
if self.cache_file:
|
|
183
|
+
with open(self.cache_file, "w") as f:
|
|
184
|
+
json.dump({"etag": self.etag, "rules": data}, f)
|
|
185
|
+
except (ValueError, OSError) as error:
|
|
186
|
+
self.core.log("firewall", "rules not used:", error)
|
|
187
|
+
return self.rules["sync"] if self.rules else RETRY
|
|
188
|
+
|
|
189
|
+
def _over_rate(self, ip, rate):
|
|
190
|
+
window = int(time.time() // rate["window"])
|
|
191
|
+
if self.counter:
|
|
192
|
+
return int(self.counter("afw:%s:%s:%s" % (self.core.site_id, ip, window), rate["window"] * 2)) > rate["limit"]
|
|
193
|
+
with self.lock:
|
|
194
|
+
entry = self.counts.get(ip)
|
|
195
|
+
if not entry or entry[0] != window:
|
|
196
|
+
if not entry and len(self.counts) >= COUNTER_MAX:
|
|
197
|
+
self.counts.clear()
|
|
198
|
+
entry = [window, 0]
|
|
199
|
+
self.counts[ip] = entry
|
|
200
|
+
entry[1] += 1
|
|
201
|
+
return entry[1] > rate["limit"]
|
|
202
|
+
|
|
203
|
+
def _banned_here(self, ip):
|
|
204
|
+
until = self.bans.get(ip)
|
|
205
|
+
if not until:
|
|
206
|
+
return False
|
|
207
|
+
if until > time.time():
|
|
208
|
+
return True
|
|
209
|
+
self.bans.pop(ip, None)
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
def _ban_here(self, ip, seconds):
|
|
213
|
+
if seconds > 0:
|
|
214
|
+
with self.lock:
|
|
215
|
+
if len(self.bans) >= COUNTER_MAX:
|
|
216
|
+
self.bans.clear()
|
|
217
|
+
self.bans[ip] = max(self.bans.get(ip, 0), time.time() + seconds)
|
|
218
|
+
|
|
219
|
+
def login_failed(self, ip):
|
|
220
|
+
"""A failed login on your site: counted here (past the site's limit the
|
|
221
|
+
address is banned straight away) and reported, so it counts on the
|
|
222
|
+
Security page and gets banned on the other servers too."""
|
|
223
|
+
key = ip_key(ip)
|
|
224
|
+
rules = self.rules
|
|
225
|
+
if key is None or (rules and rules["allow"].has(key)):
|
|
226
|
+
return
|
|
227
|
+
login = rules["login"] if rules else None
|
|
228
|
+
if login:
|
|
229
|
+
now = time.time()
|
|
230
|
+
with self.lock:
|
|
231
|
+
entry = self.logins.get(ip)
|
|
232
|
+
if not entry or now - entry[0] >= int(login.get("window") or 3600):
|
|
233
|
+
if not entry and len(self.logins) >= COUNTER_MAX:
|
|
234
|
+
self.logins.clear()
|
|
235
|
+
entry = self.logins[ip] = [now, 0]
|
|
236
|
+
entry[1] += 1
|
|
237
|
+
reached = entry[1] >= int(login["limit"])
|
|
238
|
+
if reached:
|
|
239
|
+
self.logins.pop(ip, None)
|
|
240
|
+
if reached:
|
|
241
|
+
self._ban_here(str(ip), int(login.get("ban") or 3600))
|
|
242
|
+
self._report({"ip": str(ip), "rule": "login", "action": "monitor", "method": "", "path": "", "user_agent": ""})
|
|
243
|
+
|
|
244
|
+
def _report(self, event):
|
|
245
|
+
key = "|".join((event["ip"], event["rule"], event["action"], event["method"], event["path"]))
|
|
246
|
+
with self.lock:
|
|
247
|
+
row = self.queue.get(key)
|
|
248
|
+
if row:
|
|
249
|
+
row["hits"] += 1
|
|
250
|
+
elif len(self.queue) < QUEUE_MAX:
|
|
251
|
+
self.queue[key] = dict(event, at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), hits=1)
|
|
252
|
+
|
|
253
|
+
def flush(self):
|
|
254
|
+
with self.lock:
|
|
255
|
+
rows, self.queue = list(self.queue.values()), {}
|
|
256
|
+
for i in range(0, len(rows), BATCH):
|
|
257
|
+
self.core.send("firewall", "/events/firewall/events", auth="bearer",
|
|
258
|
+
body={"site_id": self.core.site_id, "events": rows[i:i + BATCH]})
|
|
259
|
+
|
|
260
|
+
def _flush_job(self):
|
|
261
|
+
self.flush()
|
|
262
|
+
return FLUSH_EVERY
|
|
263
|
+
|
|
264
|
+
def check(self, ip, method="", path="", user_agent=""):
|
|
265
|
+
"""-> {"action": "allow" | "block" | "monitor", "rule": ..., "status": ...}.
|
|
266
|
+
Allowed addresses skip every other rule."""
|
|
267
|
+
allow = {"action": "allow", "rule": "", "status": 200}
|
|
268
|
+
rules = self.rules
|
|
269
|
+
if not rules or not rules["enabled"]:
|
|
270
|
+
return allow
|
|
271
|
+
key = ip_key(ip)
|
|
272
|
+
if key is None or rules["allow"].has(key):
|
|
273
|
+
return allow
|
|
274
|
+
action = "block" if (self.mode or rules["mode"]) == "block" else "monitor"
|
|
275
|
+
rule = ("deny" if rules["deny"].has(key)
|
|
276
|
+
else "ban" if rules["banned"].has(key) or self._banned_here(str(ip))
|
|
277
|
+
else "list" if rules["list"].has(key) else "shared" if rules["shared"].has(key) else "")
|
|
278
|
+
if not rule and is_probe(path, rules["probes"]):
|
|
279
|
+
rule = "probe"
|
|
280
|
+
# Asking for a secret file: banned here in block mode; the report bans it on the other servers.
|
|
281
|
+
if action == "block":
|
|
282
|
+
self._ban_here(str(ip), rules["probes"]["ban"])
|
|
283
|
+
matched = None
|
|
284
|
+
if not rule:
|
|
285
|
+
matched = match_request(rules.get("requests") or [], method, path, user_agent)
|
|
286
|
+
if matched:
|
|
287
|
+
rule = "request:" + matched["id"]
|
|
288
|
+
# A rule the dashboard only watches passes even in block mode;
|
|
289
|
+
# one that blocks also bans the address here, as a flood does.
|
|
290
|
+
action = "block" if action == "block" and matched["action"] == "block" else "monitor"
|
|
291
|
+
if action == "block" and matched["ban"]:
|
|
292
|
+
self._ban_here(str(ip), matched["ban"])
|
|
293
|
+
if not rule and rules["rate"] and not rules["goodbots"].has(key) and self._over_rate(str(ip), rules["rate"]):
|
|
294
|
+
rule = "rate"
|
|
295
|
+
# Banned here straight away; the report bans it on the site's other servers.
|
|
296
|
+
self._ban_here(str(ip), rules["ban"])
|
|
297
|
+
if not rule:
|
|
298
|
+
return allow
|
|
299
|
+
self._report({
|
|
300
|
+
"ip": str(ip), "rule": rule, "action": action,
|
|
301
|
+
"method": str(method)[:10], "path": str(path).split("?")[0][:300], "user_agent": str(user_agent)[:300],
|
|
302
|
+
})
|
|
303
|
+
return {"action": action, "rule": rule, "status": 429 if rule == "rate" else 403}
|
|
304
|
+
|
|
305
|
+
def retry_after(self):
|
|
306
|
+
rules = self.rules
|
|
307
|
+
return str(rules["rate"]["window"]) if rules and rules["rate"] else "60"
|
|
308
|
+
|
|
309
|
+
def client_ip(self, remote, headers):
|
|
310
|
+
return client_ip(remote, headers, self.trust_proxy)
|
|
311
|
+
|
|
312
|
+
def close(self):
|
|
313
|
+
for job in self.jobs:
|
|
314
|
+
job.stop()
|
|
315
|
+
self.flush()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Feature flags module: turn features of your app on or off from the
|
|
2
|
+
dashboard (Site settings -> Server), for everyone, a share of users, or listed
|
|
3
|
+
user IDs. is_enabled() answers from memory; the flags come with the module
|
|
4
|
+
switches (core), about once a minute.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def bucket(key, user):
|
|
9
|
+
"""FNV-1a over "key:user", 0-99: the same as the Node and PHP packages."""
|
|
10
|
+
h = 0x811C9DC5
|
|
11
|
+
for byte in ("%s:%s" % (key, user)).encode("utf-8"):
|
|
12
|
+
h ^= byte
|
|
13
|
+
h = (h * 0x01000193) & 0xFFFFFFFF
|
|
14
|
+
return h % 100
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def evaluate(flag, key, user):
|
|
18
|
+
if not flag.get("on"):
|
|
19
|
+
return False
|
|
20
|
+
uid = "" if user is None else str(user)
|
|
21
|
+
if uid and uid in (flag.get("users") or []):
|
|
22
|
+
return True
|
|
23
|
+
rollout = int(flag.get("rollout", 100))
|
|
24
|
+
if rollout >= 100:
|
|
25
|
+
return True
|
|
26
|
+
if rollout <= 0 or not uid:
|
|
27
|
+
return False
|
|
28
|
+
return bucket(key, uid) < rollout
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Flags:
|
|
32
|
+
def __init__(self, core, **_):
|
|
33
|
+
self.core = core
|
|
34
|
+
|
|
35
|
+
def is_enabled(self, key, user=None, default=False):
|
|
36
|
+
try:
|
|
37
|
+
flag = self.core.flags().get(key) if self.core.is_on("flags") else None
|
|
38
|
+
return evaluate(flag, key, user) if isinstance(flag, dict) else default
|
|
39
|
+
except Exception:
|
|
40
|
+
return default
|
|
41
|
+
|
|
42
|
+
def flush(self):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
def close(self):
|
|
46
|
+
pass
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Form protection module: checks a form your server received before you
|
|
2
|
+
accept it (POST /collectanalytics/shield/verify, the same check as spam
|
|
3
|
+
protection's). It reads the hidden "analytics-shield" answer the tracker puts
|
|
4
|
+
in protected forms, and checks the email, name and text for fake sign-ups.
|
|
5
|
+
Spam lands in the site's spam folder (Leads -> Spam). Passwords, card numbers
|
|
6
|
+
and tokens are never sent. Fails open: when the check can't be made, the form
|
|
7
|
+
is accepted (checked False).
|
|
8
|
+
"""
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
PRIVATE_FIELD = re.compile(r"pass|card|cvc|cvv|secret|token|ssn|iban|csrf|analytics-shield", re.I)
|
|
12
|
+
MAX_FIELDS = 50
|
|
13
|
+
ANSWER = "analytics-shield"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def plain_fields(fields):
|
|
17
|
+
out = {}
|
|
18
|
+
for label, value in list((fields or {}).items())[:MAX_FIELDS]:
|
|
19
|
+
if PRIVATE_FIELD.search(str(label)):
|
|
20
|
+
continue
|
|
21
|
+
if isinstance(value, (list, tuple)):
|
|
22
|
+
value = ", ".join(v for v in value if isinstance(v, str))
|
|
23
|
+
if isinstance(value, str):
|
|
24
|
+
out[str(label)[:100]] = value[:2000]
|
|
25
|
+
return out
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Forms:
|
|
29
|
+
def __init__(self, core, **_):
|
|
30
|
+
self.core = core
|
|
31
|
+
|
|
32
|
+
def check(self, fields, ip="", email=None, name=None, form=None):
|
|
33
|
+
"""fields: the form as a dict. -> {"ok", "spam", "reasons", "checked"}."""
|
|
34
|
+
unchecked = {"ok": True, "spam": False, "reasons": ["unchecked"], "checked": False}
|
|
35
|
+
if not self.core.is_on("forms"):
|
|
36
|
+
return {"ok": True, "spam": False, "reasons": [], "checked": False}
|
|
37
|
+
fields = dict(fields or {})
|
|
38
|
+
token = fields.get(ANSWER)
|
|
39
|
+
if isinstance(token, (list, tuple)):
|
|
40
|
+
token = token[0] if token else ""
|
|
41
|
+
body = {"token": token if isinstance(token, str) else "", "fields": plain_fields(fields), "ip": ip or ""}
|
|
42
|
+
for key, value in (("email", email), ("name", name), ("form", form)):
|
|
43
|
+
if isinstance(value, str):
|
|
44
|
+
body[key] = value
|
|
45
|
+
res = self.core.send("forms", "/collectanalytics/shield/verify", body=body)
|
|
46
|
+
if res is None or res.status >= 500 or res.status in (403, 429):
|
|
47
|
+
if res is not None and res.status == 403:
|
|
48
|
+
self.core.log("forms", "the site secret was refused; forms aren't checked")
|
|
49
|
+
return unchecked
|
|
50
|
+
data = res.json() or {}
|
|
51
|
+
spam = data.get("success") is False
|
|
52
|
+
return {"ok": not spam, "spam": spam, "reasons": list(data.get("reasons") or []), "checked": True}
|
|
53
|
+
|
|
54
|
+
def flush(self):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
def close(self):
|
|
58
|
+
pass
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Software inventory module (protocol in sdk/protocols/inventory.md): the
|
|
2
|
+
packages installed in the app's environment (importlib.metadata) and its
|
|
3
|
+
Python version, sent to POST /events/inventory a minute after start and then
|
|
4
|
+
once a day (the collector's `every`), so the Security check can match them
|
|
5
|
+
against known security holes. Runs in a background thread, never in a
|
|
6
|
+
request; names and versions only.
|
|
7
|
+
"""
|
|
8
|
+
import platform
|
|
9
|
+
import re
|
|
10
|
+
import socket
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
|
|
13
|
+
from .core import Periodic
|
|
14
|
+
|
|
15
|
+
FIRST = 60
|
|
16
|
+
RETRY_EVERY = 60 * 60
|
|
17
|
+
EVERY = 24 * 60 * 60
|
|
18
|
+
MAX_PACKAGES = 5000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def installed_packages():
|
|
22
|
+
"""[{"ecosystem": "PyPI", "name", "version"}], one per distribution (by
|
|
23
|
+
its normalized name), as published."""
|
|
24
|
+
try:
|
|
25
|
+
from importlib import metadata
|
|
26
|
+
except ImportError: # Python 3.7
|
|
27
|
+
return []
|
|
28
|
+
found = {}
|
|
29
|
+
for dist in metadata.distributions():
|
|
30
|
+
try:
|
|
31
|
+
name = dist.metadata["Name"]
|
|
32
|
+
version = dist.version
|
|
33
|
+
except Exception:
|
|
34
|
+
continue
|
|
35
|
+
if not name or not version:
|
|
36
|
+
continue
|
|
37
|
+
key = re.sub(r"[-_.]+", "-", name).lower()
|
|
38
|
+
found.setdefault(key, {"ecosystem": "PyPI", "name": name, "version": str(version)})
|
|
39
|
+
return sorted(found.values(), key=lambda p: p["name"].lower())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Inventory:
|
|
43
|
+
"""Options: host (the server's name; default the host name)."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, core, host=None, **_):
|
|
46
|
+
self.core = core
|
|
47
|
+
self.host = re.sub(r"[^A-Za-z0-9._-]", "-", str(host if host is not None else socket.gethostname()))[:100] or "server"
|
|
48
|
+
self.job = Periodic(self._report, FIRST) if core.valid else None
|
|
49
|
+
|
|
50
|
+
def _report(self):
|
|
51
|
+
if not self.core.is_on("inventory"):
|
|
52
|
+
return RETRY_EVERY
|
|
53
|
+
packages = installed_packages()
|
|
54
|
+
res = self.core.send("inventory", "/events/inventory", auth="bearer", body={
|
|
55
|
+
"site_id": self.core.site_id,
|
|
56
|
+
"host": self.host,
|
|
57
|
+
"at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
58
|
+
"runtime": {"python": platform.python_version()},
|
|
59
|
+
"packages": packages[:MAX_PACKAGES],
|
|
60
|
+
"truncated": len(packages) > MAX_PACKAGES,
|
|
61
|
+
})
|
|
62
|
+
if res is None or not res.ok:
|
|
63
|
+
return RETRY_EVERY
|
|
64
|
+
every = (res.json() or {}).get("every")
|
|
65
|
+
return max(RETRY_EVERY, int(every)) if isinstance(every, int) and every > 0 else EVERY
|
|
66
|
+
|
|
67
|
+
def flush(self):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
def close(self):
|
|
71
|
+
if self.job:
|
|
72
|
+
self.job.stop()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Maintenance mode module: while it's on (Site settings -> Server), visitors
|
|
2
|
+
get a "back soon" page (503, so search engines know it's temporary), except
|
|
3
|
+
allowed addresses and whoever opened the preview link (?site_preview=<code>,
|
|
4
|
+
remembered for a day in a cookie).
|
|
5
|
+
"""
|
|
6
|
+
import html
|
|
7
|
+
from http.cookies import SimpleCookie
|
|
8
|
+
from urllib.parse import parse_qs
|
|
9
|
+
|
|
10
|
+
from .firewall import IpSet, ip_key
|
|
11
|
+
|
|
12
|
+
COOKIE = "site_preview"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def page(message):
|
|
16
|
+
text = html.escape(message or "We're doing some maintenance. Please check back in a few minutes.")
|
|
17
|
+
return ("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
|
|
18
|
+
"<title>Back soon</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;font:16px/1.5 system-ui,sans-serif;"
|
|
19
|
+
"background:#f8fafc;color:#0f172a}main{max-width:32rem;padding:2rem;text-align:center}h1{font-size:1.6rem;margin:0 0 .5rem}"
|
|
20
|
+
"p{color:#475569;margin:0}@media(prefers-color-scheme:dark){body{background:#0f172a;color:#e2e8f0}p{color:#94a3b8}}</style></head>"
|
|
21
|
+
"<body><main><h1>Back soon</h1><p>%s</p></main></body></html>" % text).encode("utf-8")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Maintenance:
|
|
25
|
+
"""Options: skip(path) -> True lets a request through (health checks)."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, core, skip=None, **_):
|
|
28
|
+
self.core = core
|
|
29
|
+
self.skip = skip
|
|
30
|
+
self._allow_for = None
|
|
31
|
+
self._allow = IpSet([])
|
|
32
|
+
|
|
33
|
+
def decide(self, ip, path, query, cookie_header):
|
|
34
|
+
"""None to let the request through, else (status, headers, body)."""
|
|
35
|
+
m = self.core.maintenance() if self.core.is_on("maintenance") else None
|
|
36
|
+
if not m or not m.get("on") or (self.skip and self.skip(path)):
|
|
37
|
+
return None
|
|
38
|
+
if self._allow_for is not m.get("allow"):
|
|
39
|
+
self._allow_for = m.get("allow")
|
|
40
|
+
self._allow = IpSet(m.get("allow") or [])
|
|
41
|
+
key = ip_key(ip)
|
|
42
|
+
if key is not None and self._allow.has(key):
|
|
43
|
+
return None
|
|
44
|
+
code = m.get("bypass") or ""
|
|
45
|
+
if code:
|
|
46
|
+
if parse_qs(query or "").get(COOKIE, [""])[0] == code:
|
|
47
|
+
return ("pass", [("Set-Cookie", "%s=%s; Path=/; Max-Age=86400; HttpOnly; SameSite=Lax" % (COOKIE, code))])
|
|
48
|
+
cookies = SimpleCookie()
|
|
49
|
+
try:
|
|
50
|
+
cookies.load(cookie_header or "")
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
if COOKIE in cookies and cookies[COOKIE].value == code:
|
|
54
|
+
return None
|
|
55
|
+
return (503, [("Content-Type", "text/html; charset=utf-8"), ("Retry-After", "600"), ("Cache-Control", "no-store")], page(m.get("message")))
|
|
56
|
+
|
|
57
|
+
def flush(self):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
def close(self):
|
|
61
|
+
pass
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Performance & API monitoring module: times every request (from arriving
|
|
2
|
+
to the response being ready) and sends one row per minute, method, route and
|
|
3
|
+
status code (how many, total and slowest time, and a count per time bucket)
|
|
4
|
+
to POST /events/server-requests. Nothing about visitors is sent.
|
|
5
|
+
|
|
6
|
+
The route is the framework's own pattern when the integration knows it
|
|
7
|
+
(Django, Flask, FastAPI); otherwise numbers and IDs in the path become ":id",
|
|
8
|
+
and unmatched 404s are one route.
|
|
9
|
+
"""
|
|
10
|
+
import platform as host
|
|
11
|
+
import re
|
|
12
|
+
import socket
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
|
|
17
|
+
from .core import Periodic
|
|
18
|
+
|
|
19
|
+
# Keep in step with the collector (expresscollect/Analytics/serverRequests.js).
|
|
20
|
+
BUCKET_EDGES_MS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000]
|
|
21
|
+
FLUSH_EVERY = 60
|
|
22
|
+
MAX_KEYS = 300
|
|
23
|
+
MAX_ROWS = 500
|
|
24
|
+
NOT_FOUND = "/(not found)"
|
|
25
|
+
_ID = re.compile(r"^(\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{16,})$", re.I)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def normalize_route(path):
|
|
29
|
+
"""'/orders/123/items/9f8e...' -> '/orders/:id/items/:id'"""
|
|
30
|
+
path = re.split(r"[?#]", str(path or "/"))[0] or "/"
|
|
31
|
+
parts = [":id" if _ID.match(s) or (len(s) >= 20 and re.search(r"\d", s)) else s for s in path.split("/")]
|
|
32
|
+
route = "/".join(parts)
|
|
33
|
+
return (route if route.startswith("/") else "/" + route)[:200]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def bucket_of(ms):
|
|
37
|
+
for i, edge in enumerate(BUCKET_EDGES_MS):
|
|
38
|
+
if ms <= edge:
|
|
39
|
+
return i
|
|
40
|
+
return len(BUCKET_EDGES_MS)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Performance:
|
|
44
|
+
"""Options: server_name (default the host name)."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, core, server_name=None, **_):
|
|
47
|
+
self.core = core
|
|
48
|
+
self.server_name = str(server_name or socket.gethostname())[:100]
|
|
49
|
+
self.runtime = "Python " + host.python_version()
|
|
50
|
+
self.rows = {}
|
|
51
|
+
self.lock = threading.Lock()
|
|
52
|
+
self.job = Periodic(self._flush_job, FLUSH_EVERY) if core.valid else None
|
|
53
|
+
|
|
54
|
+
def record(self, method, route, status, ms, path=""):
|
|
55
|
+
"""One finished request. route: the framework's pattern, or "" to
|
|
56
|
+
work it out from path."""
|
|
57
|
+
if not self.core.is_on("performance"):
|
|
58
|
+
return
|
|
59
|
+
status = int(status or 0)
|
|
60
|
+
if not route:
|
|
61
|
+
route = NOT_FOUND if status == 404 else normalize_route(path)
|
|
62
|
+
method = str(method or "GET").upper()[:10]
|
|
63
|
+
minute = int(time.time() // 60) * 60
|
|
64
|
+
with self.lock:
|
|
65
|
+
key = (minute, method, route[:200], status)
|
|
66
|
+
if key not in self.rows and len(self.rows) >= MAX_KEYS:
|
|
67
|
+
key = (minute, method, "(other)", status)
|
|
68
|
+
row = self.rows.get(key)
|
|
69
|
+
if row is None:
|
|
70
|
+
row = self.rows[key] = {"count": 0, "sum_ms": 0.0, "max_ms": 0.0, "buckets": [0] * (len(BUCKET_EDGES_MS) + 1)}
|
|
71
|
+
row["count"] += 1
|
|
72
|
+
row["sum_ms"] += ms
|
|
73
|
+
row["max_ms"] = max(row["max_ms"], ms)
|
|
74
|
+
row["buckets"][bucket_of(ms)] += 1
|
|
75
|
+
|
|
76
|
+
def flush(self, everything=True):
|
|
77
|
+
current = int(time.time() // 60) * 60
|
|
78
|
+
with self.lock:
|
|
79
|
+
ready = [(k, v) for k, v in self.rows.items() if everything or k[0] < current]
|
|
80
|
+
for k, _ in ready:
|
|
81
|
+
del self.rows[k]
|
|
82
|
+
rows = [{
|
|
83
|
+
"minute": datetime.fromtimestamp(k[0], timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
84
|
+
"method": k[1], "route": k[2], "status": k[3], "count": v["count"],
|
|
85
|
+
"sum_ms": round(v["sum_ms"], 1), "max_ms": round(v["max_ms"], 1), "buckets": v["buckets"],
|
|
86
|
+
} for k, v in ready]
|
|
87
|
+
for i in range(0, len(rows), MAX_ROWS):
|
|
88
|
+
self.core.send("performance", "/events/server-requests",
|
|
89
|
+
body={"server_name": self.server_name, "runtime": self.runtime, "rows": rows[i:i + MAX_ROWS]})
|
|
90
|
+
|
|
91
|
+
def _flush_job(self):
|
|
92
|
+
self.flush(everything=False)
|
|
93
|
+
return FLUSH_EVERY
|
|
94
|
+
|
|
95
|
+
def close(self):
|
|
96
|
+
if self.job:
|
|
97
|
+
self.job.stop()
|
|
98
|
+
self.flush()
|