analytics-platform 0.2.1__tar.gz → 0.2.2__tar.gz
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-0.2.1 → analytics_platform-0.2.2}/PKG-INFO +2 -2
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/README.md +1 -1
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/__init__.py +18 -4
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/core.py +26 -1
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/django.py +3 -1
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/firewall.py +3 -2
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/proxy.py +1 -0
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/request_rules.py +96 -17
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/resources.py +22 -3
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform.egg-info/PKG-INFO +2 -2
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform.egg-info/SOURCES.txt +1 -0
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/pyproject.toml +1 -1
- analytics_platform-0.2.2/tests/test_firewall.py +56 -0
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/tests/test_request_rules.py +32 -0
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/tests/test_resources.py +35 -0
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/LICENSE +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/analytics.py +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/audit.py +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/crawlers.py +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/errors.py +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/flags.py +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/forms.py +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/inventory.py +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/maintenance.py +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/performance.py +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/posture.py +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform.egg-info/dependency_links.txt +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform.egg-info/top_level.txt +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/setup.cfg +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/tests/test_crawlers.py +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.2.2}/tests/test_proxy.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: analytics-platform
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard.
|
|
5
5
|
Author: steviec87
|
|
6
6
|
License-Expression: MIT
|
|
@@ -103,7 +103,7 @@ create_platform(site_id=..., secret=..., endpoint=...,
|
|
|
103
103
|
analytics=False)
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
-
**Switch modules off in the dashboard** (Security →
|
|
106
|
+
**Switch modules off in the dashboard** (Security → Server package) without a
|
|
107
107
|
deploy: the package checks about once a minute. Modules are independent: one
|
|
108
108
|
failing to start, or the collector being down, never stops the others, and
|
|
109
109
|
never slows your requests. Background work runs in daemon threads, which start
|
|
@@ -77,7 +77,7 @@ create_platform(site_id=..., secret=..., endpoint=...,
|
|
|
77
77
|
analytics=False)
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
-
**Switch modules off in the dashboard** (Security →
|
|
80
|
+
**Switch modules off in the dashboard** (Security → Server package) without a
|
|
81
81
|
deploy: the package checks about once a minute. Modules are independent: one
|
|
82
82
|
failing to start, or the collector being down, never stops the others, and
|
|
83
83
|
never slows your requests. Background work runs in daemon threads, which start
|
|
@@ -16,7 +16,7 @@ No dependencies; Python 3.8+.
|
|
|
16
16
|
import asyncio
|
|
17
17
|
import time
|
|
18
18
|
|
|
19
|
-
from .core import Core, SESSION_HEADER, VERSION, header
|
|
19
|
+
from .core import Core, SESSION_HEADER, VERSION, header, request_target
|
|
20
20
|
|
|
21
21
|
__all__ = ["create_platform", "Platform", "PlatformMiddleware", "VERSION"]
|
|
22
22
|
|
|
@@ -286,7 +286,8 @@ class Platform:
|
|
|
286
286
|
|
|
287
287
|
def blocked(self, remote, headers, method, path):
|
|
288
288
|
"""(status, body, headers) when the firewall turns the request away,
|
|
289
|
-
else None. headers: a dict with lowercase names.
|
|
289
|
+
else None. headers: a dict with lowercase names. path: the request
|
|
290
|
+
target as sent, query string and all, not yet decoded (request_target)."""
|
|
290
291
|
firewall = self._modules.get("firewall")
|
|
291
292
|
if not firewall:
|
|
292
293
|
return None
|
|
@@ -321,7 +322,7 @@ class Platform:
|
|
|
321
322
|
start_response("%d " % status, list(extra))
|
|
322
323
|
return [body]
|
|
323
324
|
blocked = platform.blocked(environ.get("REMOTE_ADDR", ""), headers, environ.get("REQUEST_METHOD", ""),
|
|
324
|
-
environ
|
|
325
|
+
_wsgi_target(environ))
|
|
325
326
|
if blocked:
|
|
326
327
|
status, body, extra = blocked
|
|
327
328
|
start_response("%d %s" % (status, body.decode()), extra)
|
|
@@ -387,6 +388,17 @@ class Platform:
|
|
|
387
388
|
return PlatformMiddleware(app, platform=self)
|
|
388
389
|
|
|
389
390
|
|
|
391
|
+
def _wsgi_target(environ):
|
|
392
|
+
# gunicorn keeps the target as sent (RAW_URI), uWSGI and mod_wsgi too
|
|
393
|
+
# (REQUEST_URI); PATH_INFO is decoded, and PEP 3333 hands its bytes over as latin-1.
|
|
394
|
+
path = environ.get("SCRIPT_NAME", "") + environ.get("PATH_INFO", "")
|
|
395
|
+
try:
|
|
396
|
+
path = path.encode("latin-1")
|
|
397
|
+
except UnicodeEncodeError:
|
|
398
|
+
pass
|
|
399
|
+
return request_target(path, environ.get("QUERY_STRING", ""), environ.get("RAW_URI") or environ.get("REQUEST_URI"))
|
|
400
|
+
|
|
401
|
+
|
|
390
402
|
def _wsgi_body(environ):
|
|
391
403
|
try:
|
|
392
404
|
length = int(environ.get("CONTENT_LENGTH") or 0)
|
|
@@ -439,7 +451,9 @@ class PlatformMiddleware:
|
|
|
439
451
|
"headers": [(k.lower().encode(), str(v).encode()) for k, v in extra]})
|
|
440
452
|
await send({"type": "http.response.body", "body": out})
|
|
441
453
|
return
|
|
442
|
-
|
|
454
|
+
# raw_path is the path as sent; servers that leave it out get path encoded again.
|
|
455
|
+
target = request_target(scope.get("path", ""), scope.get("query_string", b""), scope.get("raw_path"))
|
|
456
|
+
blocked = self.platform.blocked(client[0] or "", headers, scope.get("method", ""), target)
|
|
443
457
|
if blocked:
|
|
444
458
|
status, body, extra = blocked
|
|
445
459
|
await send({"type": "http.response.start", "status": status,
|
|
@@ -13,9 +13,10 @@ import os
|
|
|
13
13
|
import re
|
|
14
14
|
import threading
|
|
15
15
|
import urllib.error
|
|
16
|
+
import urllib.parse
|
|
16
17
|
import urllib.request
|
|
17
18
|
|
|
18
|
-
VERSION = "0.
|
|
19
|
+
VERSION = "0.2.2"
|
|
19
20
|
TIMEOUT = 5
|
|
20
21
|
MIN_SYNC = 30
|
|
21
22
|
RETRY = 60
|
|
@@ -197,3 +198,27 @@ def header(headers, name):
|
|
|
197
198
|
"""A header from a dict with lowercase names (or None)."""
|
|
198
199
|
value = headers.get(name.lower())
|
|
199
200
|
return value.strip() if isinstance(value, str) else ""
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# The characters a path may carry unencoded (RFC 3986), which quote() leaves alone.
|
|
204
|
+
_PATH_SAFE = "/:@!$&'()*+,;=-._~"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def request_target(path, query="", raw=None):
|
|
208
|
+
"""The request target as the visitor sent it, query string and all, which
|
|
209
|
+
is what the request rules match (sdk/protocols/firewall.md): the Node and
|
|
210
|
+
PHP clients hand over req.url and REQUEST_URI. raw: the server's own copy
|
|
211
|
+
of it when it keeps one. Otherwise the path the framework has already
|
|
212
|
+
decoded is encoded again, so the rules' one decoding pass is the only one.
|
|
213
|
+
path: text, or the bytes it was decoded from."""
|
|
214
|
+
if isinstance(raw, bytes):
|
|
215
|
+
raw = raw.decode("latin-1")
|
|
216
|
+
if isinstance(raw, str) and raw:
|
|
217
|
+
target = raw
|
|
218
|
+
else:
|
|
219
|
+
target = urllib.parse.quote(path or "", safe=_PATH_SAFE)
|
|
220
|
+
if isinstance(query, bytes):
|
|
221
|
+
query = query.decode("latin-1")
|
|
222
|
+
if query and "?" not in target:
|
|
223
|
+
target += "?" + query
|
|
224
|
+
return target
|
|
@@ -74,7 +74,9 @@ class PlatformMiddleware:
|
|
|
74
74
|
for name, value in extra:
|
|
75
75
|
response[name] = value
|
|
76
76
|
return response
|
|
77
|
-
|
|
77
|
+
# The target as sent: request.path is decoded, get_full_path() encodes it again.
|
|
78
|
+
target = request.META.get("RAW_URI") or request.META.get("REQUEST_URI") or request.get_full_path()
|
|
79
|
+
blocked = self.platform.blocked(ip, headers, request.method, target)
|
|
78
80
|
if blocked:
|
|
79
81
|
status, body, extra = blocked
|
|
80
82
|
response = HttpResponse(body, status=status)
|
|
@@ -292,8 +292,9 @@ class Firewall:
|
|
|
292
292
|
self._ban_here(str(ip), matched["ban"])
|
|
293
293
|
if not rule and rules["rate"] and not rules["goodbots"].has(key) and self._over_rate(str(ip), rules["rate"]):
|
|
294
294
|
rule = "rate"
|
|
295
|
-
# Banned here straight away; the report bans it on the
|
|
296
|
-
|
|
295
|
+
# Banned here straight away in block mode, as a probe is; the report bans it on the other servers.
|
|
296
|
+
if action == "block":
|
|
297
|
+
self._ban_here(str(ip), rules["ban"])
|
|
297
298
|
if not rule:
|
|
298
299
|
return allow
|
|
299
300
|
self._report({
|
|
@@ -27,6 +27,7 @@ import urllib.request
|
|
|
27
27
|
FILES = frozenset((
|
|
28
28
|
"track.js", "s.js", "snapshot.js", "replay.js", "survey.js", "banner.js",
|
|
29
29
|
"leadform.js", "notice.js", "editor.js", "picker.js", "feedback.js", "shield.js",
|
|
30
|
+
"widget.js", "share.js",
|
|
30
31
|
))
|
|
31
32
|
# A pinned version never changes; /v<major>/ follows new releases, so it is
|
|
32
33
|
# held for an hour, the same as the collector serves it with.
|
|
@@ -29,14 +29,18 @@ def safe_pattern(pattern):
|
|
|
29
29
|
"""A pattern can only come from us, but a careless one would be matched
|
|
30
30
|
against text an attacker chooses, in this very request, and `re` can't be
|
|
31
31
|
timed out. So the client checks the shape itself instead of trusting what it
|
|
32
|
-
was handed
|
|
33
|
-
exponential time on input that nearly matches
|
|
34
|
-
|
|
32
|
+
was handed. A repeated group that can match the same text more than one way
|
|
33
|
+
takes exponential time on input that nearly matches; a group can do that
|
|
34
|
+
when it holds a quantifier ("(a+)+", "(\\w\\d?)+") or an alternation
|
|
35
|
+
("(a|ab)*", "(\\w|\\d)+"), so a group followed by * + or { holds neither.
|
|
36
|
+
Backreferences and lookbehind are refused too. The same check as
|
|
37
|
+
backend/services/firewall/requestRules.js, free repeats included."""
|
|
35
38
|
if not isinstance(pattern, str) or not pattern or len(pattern) > MAX_REGEX:
|
|
36
39
|
return False
|
|
37
40
|
if re.search(r"\\[1-9]", pattern) or "(?<" in pattern:
|
|
38
41
|
return False
|
|
39
|
-
|
|
42
|
+
# Per open group: whether anything in it, nested groups too, repeats or branches.
|
|
43
|
+
open_groups = []
|
|
40
44
|
i = 0
|
|
41
45
|
while i < len(pattern):
|
|
42
46
|
char = pattern[i]
|
|
@@ -44,25 +48,97 @@ def safe_pattern(pattern):
|
|
|
44
48
|
i += 2
|
|
45
49
|
continue
|
|
46
50
|
if char == "[":
|
|
47
|
-
# A character class: quantifiers inside it are literals.
|
|
48
|
-
i += 1
|
|
51
|
+
# A character class: skipped whole, quantifiers inside it are literals.
|
|
49
52
|
while i < len(pattern) and pattern[i] != "]":
|
|
50
53
|
i += 2 if pattern[i] == "\\" else 1
|
|
51
54
|
i += 1
|
|
52
55
|
continue
|
|
53
56
|
if char == "(":
|
|
54
|
-
|
|
57
|
+
open_groups.append(False)
|
|
58
|
+
# "(?:", "(?=", "(?!": that ? names the kind of group, it repeats nothing.
|
|
59
|
+
if pattern[i + 1:i + 2] == "?":
|
|
60
|
+
i += 2
|
|
55
61
|
elif char == ")":
|
|
56
|
-
if not
|
|
62
|
+
if not open_groups:
|
|
63
|
+
return False
|
|
64
|
+
ambiguous = open_groups.pop()
|
|
65
|
+
nxt = pattern[i + 1:i + 2]
|
|
66
|
+
if ambiguous and nxt in ("*", "+", "{"):
|
|
57
67
|
return False
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
68
|
+
if ambiguous and open_groups:
|
|
69
|
+
open_groups[-1] = True
|
|
70
|
+
elif open_groups and char in "|*+?{":
|
|
71
|
+
open_groups[-1] = True
|
|
72
|
+
i += 1
|
|
73
|
+
return not open_groups and _free_repeats(pattern) <= 1
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _literal_at(pattern, i):
|
|
77
|
+
if i >= len(pattern):
|
|
78
|
+
return None
|
|
79
|
+
char = pattern[i]
|
|
80
|
+
if char == "\\":
|
|
81
|
+
following = pattern[i + 1:i + 2]
|
|
82
|
+
return following if following and not re.match(r"[a-z0-9]", following, re.I) else None
|
|
83
|
+
return None if char in ".[](){}*+?|^$" else char
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _free_repeats(pattern):
|
|
87
|
+
"""Two repeats that can both take the same text multiply each other's
|
|
88
|
+
work: ".*=.*=.*x" against a query of "=" that never ends in "x" is
|
|
89
|
+
polynomial, and 2,000 characters is plenty for that to hang a request. A
|
|
90
|
+
repeat is pinned when the character straight after it is one it can't
|
|
91
|
+
match ("[^/]+/" ends at the first "/", once); any other is free, and a
|
|
92
|
+
pattern may hold one free repeat."""
|
|
93
|
+
free = 0
|
|
94
|
+
i = 0
|
|
95
|
+
while i < len(pattern):
|
|
96
|
+
start = i
|
|
97
|
+
char = pattern[i]
|
|
98
|
+
if char == "\\":
|
|
99
|
+
i += 2
|
|
100
|
+
elif char == "[":
|
|
101
|
+
i += 1
|
|
102
|
+
while i < len(pattern) and pattern[i] != "]":
|
|
103
|
+
i += 2 if pattern[i] == "\\" else 1
|
|
104
|
+
i += 1
|
|
105
|
+
elif char == "(":
|
|
106
|
+
i += 3 if pattern[i + 1:i + 2] == "?" else 1
|
|
107
|
+
continue
|
|
108
|
+
elif char in "|^$":
|
|
109
|
+
i += 1
|
|
110
|
+
continue
|
|
111
|
+
else:
|
|
112
|
+
i += 1
|
|
113
|
+
# A group's text can't be tried against one character.
|
|
114
|
+
atom = None if char == ")" else pattern[start:i]
|
|
115
|
+
quantifier = pattern[i:i + 1]
|
|
116
|
+
repeats = quantifier in ("*", "+")
|
|
117
|
+
if quantifier == "{":
|
|
118
|
+
bounds = re.match(r"\{(\d*)(,(\d*))?\}", pattern[i:])
|
|
119
|
+
if not bounds:
|
|
120
|
+
continue
|
|
121
|
+
# "{3}" is one length, not a choice; "{2,}" and "{2,9}" are choices.
|
|
122
|
+
repeats = bounds.group(2) is not None and bounds.group(3) != bounds.group(1)
|
|
123
|
+
i += len(bounds.group(0)) - 1
|
|
124
|
+
elif quantifier != "?" and not repeats:
|
|
125
|
+
continue
|
|
64
126
|
i += 1
|
|
65
|
-
|
|
127
|
+
if pattern[i:i + 1] == "?":
|
|
128
|
+
i += 1
|
|
129
|
+
if not repeats:
|
|
130
|
+
continue
|
|
131
|
+
nxt = _literal_at(pattern, i)
|
|
132
|
+
pinned = False
|
|
133
|
+
if atom and nxt is not None:
|
|
134
|
+
try:
|
|
135
|
+
pinned = re.fullmatch("(?:%s)" % atom, nxt, re.IGNORECASE) is None
|
|
136
|
+
except re.error:
|
|
137
|
+
# Not an atom on its own: free.
|
|
138
|
+
pass
|
|
139
|
+
if not pinned:
|
|
140
|
+
free += 1
|
|
141
|
+
return free
|
|
66
142
|
|
|
67
143
|
|
|
68
144
|
def _section(section):
|
|
@@ -157,10 +233,13 @@ def match_request(rules, method="", path="", user_agent=""):
|
|
|
157
233
|
target = str(path or "")
|
|
158
234
|
mark = target.find("?")
|
|
159
235
|
raw_path = _normalize_path((target if mark == -1 else target[:mark])[:MAX_TEXT])
|
|
160
|
-
|
|
236
|
+
query = ("" if mark == -1 else target[mark + 1:])[:MAX_TEXT]
|
|
237
|
+
raw_query = query.lower()
|
|
161
238
|
ua = str(user_agent or "")[:MAX_TEXT].lower()
|
|
162
239
|
decoded_path = _normalize_path(_decode_once((target if mark == -1 else target[:mark])[:MAX_TEXT])[:MAX_TEXT])
|
|
163
|
-
|
|
240
|
+
# Lower-cased after decoding: %55NION decodes to "UNION", which a
|
|
241
|
+
# lower-cased "union select" rule would otherwise never see.
|
|
242
|
+
decoded_query = _decode_once(query)[:MAX_TEXT].lower()
|
|
164
243
|
upper = str(method or "").upper()
|
|
165
244
|
for rule in rules:
|
|
166
245
|
if rule["methods"] and upper not in rule["methods"]:
|
|
@@ -87,9 +87,16 @@ def cpu_fields(text):
|
|
|
87
87
|
return None
|
|
88
88
|
|
|
89
89
|
|
|
90
|
+
def cpu_total(fields):
|
|
91
|
+
"""Every jiffy in the counters once. guest and guest_nice (columns 9 and
|
|
92
|
+
10) are already inside user and nice, so they are left out, as psutil and
|
|
93
|
+
top do: counting them again reads a busy hypervisor as half idle."""
|
|
94
|
+
return sum(fields[:8])
|
|
95
|
+
|
|
96
|
+
|
|
90
97
|
def cpu_times_of(fields):
|
|
91
98
|
"""(busy, total) jiffies from the counters, for cpu_percent."""
|
|
92
|
-
total =
|
|
99
|
+
total = cpu_total(fields)
|
|
93
100
|
idle = fields[3] + (fields[4] if len(fields) > 4 else 0)
|
|
94
101
|
return total - idle, total
|
|
95
102
|
|
|
@@ -122,7 +129,7 @@ def cpu_shares(before, after):
|
|
|
122
129
|
will change."""
|
|
123
130
|
if not before or not after:
|
|
124
131
|
return {}
|
|
125
|
-
total =
|
|
132
|
+
total = cpu_total(after) - cpu_total(before)
|
|
126
133
|
if total <= 0:
|
|
127
134
|
return {}
|
|
128
135
|
out = {}
|
|
@@ -410,6 +417,11 @@ class Resources:
|
|
|
410
417
|
if uptime is not None:
|
|
411
418
|
row["host_uptime_s"] = uptime
|
|
412
419
|
with self.lock:
|
|
420
|
+
if not self.enabled:
|
|
421
|
+
# Off in the dashboard: only the newest minute is kept, as the
|
|
422
|
+
# one row that asks the collector, now and then, whether it is
|
|
423
|
+
# back on (the answer is the only place that says so).
|
|
424
|
+
self.rows.clear()
|
|
413
425
|
self.rows[row["minute"]] = row
|
|
414
426
|
# Oldest first out: a long outage keeps the recent minutes.
|
|
415
427
|
while len(self.rows) > MAX_ROWS:
|
|
@@ -462,10 +474,17 @@ class Resources:
|
|
|
462
474
|
with self.lock:
|
|
463
475
|
self.rows.clear()
|
|
464
476
|
return max(OFF_EVERY, self.every)
|
|
465
|
-
|
|
477
|
+
sent = self.flush()
|
|
478
|
+
if not self.enabled:
|
|
479
|
+
# The collector said the module is off: ask again later, as the agent does.
|
|
480
|
+
return max(OFF_EVERY, self.every)
|
|
481
|
+
return self.every if sent else RETRY_EVERY
|
|
466
482
|
|
|
467
483
|
def close(self):
|
|
468
484
|
if self.sampler:
|
|
469
485
|
self.sampler.stop()
|
|
470
486
|
if self.job:
|
|
471
487
|
self.job.stop()
|
|
488
|
+
# What's buffered goes out, as with every other module.
|
|
489
|
+
if self.core.is_on("resources") and self.enabled:
|
|
490
|
+
self.flush()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: analytics-platform
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard.
|
|
5
5
|
Author: steviec87
|
|
6
6
|
License-Expression: MIT
|
|
@@ -103,7 +103,7 @@ create_platform(site_id=..., secret=..., endpoint=...,
|
|
|
103
103
|
analytics=False)
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
-
**Switch modules off in the dashboard** (Security →
|
|
106
|
+
**Switch modules off in the dashboard** (Security → Server package) without a
|
|
107
107
|
deploy: the package checks about once a minute. Modules are independent: one
|
|
108
108
|
failing to start, or the collector being down, never stops the others, and
|
|
109
109
|
never slows your requests. Background work runs in daemon threads, which start
|
{analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform.egg-info/SOURCES.txt
RENAMED
|
@@ -23,6 +23,7 @@ analytics_platform.egg-info/SOURCES.txt
|
|
|
23
23
|
analytics_platform.egg-info/dependency_links.txt
|
|
24
24
|
analytics_platform.egg-info/top_level.txt
|
|
25
25
|
tests/test_crawlers.py
|
|
26
|
+
tests/test_firewall.py
|
|
26
27
|
tests/test_proxy.py
|
|
27
28
|
tests/test_request_rules.py
|
|
28
29
|
tests/test_resources.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "analytics-platform"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The firewall's own decisions: when an address gets banned here, and what
|
|
2
|
+
the middleware hands the request rules.
|
|
3
|
+
|
|
4
|
+
python3 -m unittest discover -s tests
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import unittest
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
11
|
+
|
|
12
|
+
from analytics_platform import _wsgi_target # noqa: E402
|
|
13
|
+
from analytics_platform.firewall import Firewall # noqa: E402
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FakeCore:
|
|
17
|
+
valid = False
|
|
18
|
+
site_id = "a" * 24
|
|
19
|
+
|
|
20
|
+
def log(self, *_args):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def firewall(mode):
|
|
25
|
+
wall = Firewall(FakeCore())
|
|
26
|
+
wall.rules = wall._compile({"v": 1, "enabled": True, "mode": mode, "rate": {"limit": 2, "window": 60},
|
|
27
|
+
"ban": {"seconds": 600}})
|
|
28
|
+
return wall
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RateBan(unittest.TestCase):
|
|
32
|
+
def test_monitor_mode_bans_nobody(self):
|
|
33
|
+
wall = firewall("monitor")
|
|
34
|
+
rules = [wall.check("203.0.113.9", "GET", "/")["rule"] for _ in range(5)]
|
|
35
|
+
self.assertEqual(rules, ["", "", "rate", "rate", "rate"], "over the limit, reported as rate, never banned")
|
|
36
|
+
self.assertEqual(wall.bans, {})
|
|
37
|
+
|
|
38
|
+
def test_block_mode_bans_here(self):
|
|
39
|
+
wall = firewall("block")
|
|
40
|
+
rules = [wall.check("203.0.113.9", "GET", "/")["rule"] for _ in range(4)]
|
|
41
|
+
self.assertEqual(rules, ["", "", "rate", "ban"])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class WsgiTarget(unittest.TestCase):
|
|
45
|
+
def test_the_raw_target_is_used_when_the_server_keeps_it(self):
|
|
46
|
+
self.assertEqual(_wsgi_target({"RAW_URI": "/a%252e?q=1", "PATH_INFO": "/a%2e", "QUERY_STRING": "q=1"}),
|
|
47
|
+
"/a%252e?q=1")
|
|
48
|
+
|
|
49
|
+
def test_otherwise_path_info_is_encoded_again_with_its_query(self):
|
|
50
|
+
# PEP 3333: the bytes as latin-1; here a UTF-8 "é".
|
|
51
|
+
environ = {"SCRIPT_NAME": "/app", "PATH_INFO": "/café", "QUERY_STRING": "id=1%20union"}
|
|
52
|
+
self.assertEqual(_wsgi_target(environ), "/app/caf%C3%A9?id=1%20union")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
unittest.main()
|
|
@@ -63,6 +63,14 @@ class PatternSafety(unittest.TestCase):
|
|
|
63
63
|
for pattern in (r"/(wp-content/uploads|uploads)/[^?]*\.(php[0-9]?|phtml)$", r"(includes|inc|lib)?/?[^/]*\.php$"):
|
|
64
64
|
self.assertTrue(safe_pattern(pattern), pattern)
|
|
65
65
|
|
|
66
|
+
def test_the_shared_pattern_lists_decide_the_same_way(self):
|
|
67
|
+
with open(CASES) as handle:
|
|
68
|
+
patterns = json.load(handle)["patterns"]
|
|
69
|
+
for pattern in patterns["refused"]:
|
|
70
|
+
self.assertFalse(safe_pattern(pattern), pattern)
|
|
71
|
+
for pattern in patterns["allowed"]:
|
|
72
|
+
self.assertTrue(safe_pattern(pattern), pattern)
|
|
73
|
+
|
|
66
74
|
def test_an_unsafe_rule_is_dropped_and_matching_stays_quick(self):
|
|
67
75
|
rules = compile_rules({"rules": [
|
|
68
76
|
{"id": "slow", "action": "block", "path": {"regex": "(a+)+b"}},
|
|
@@ -74,5 +82,29 @@ class PatternSafety(unittest.TestCase):
|
|
|
74
82
|
self.assertLess(time.time() - started, 1.0)
|
|
75
83
|
|
|
76
84
|
|
|
85
|
+
class RequestTarget(unittest.TestCase):
|
|
86
|
+
"""The rules match the target as sent; frameworks hand over a decoded path."""
|
|
87
|
+
|
|
88
|
+
def test_a_decoded_path_is_encoded_again_so_it_is_decoded_once(self):
|
|
89
|
+
from analytics_platform.core import request_target
|
|
90
|
+
# Sent as /a%252e%252e/, which the framework decoded to /a%2e%2e/.
|
|
91
|
+
self.assertEqual(request_target("/a%2e%2e/"), "/a%252e%252e/")
|
|
92
|
+
rules = compile_rules({"rules": [{"id": "dots", "action": "block", "path": {"contains": ["/a../"]}}]})
|
|
93
|
+
self.assertIsNone(match_request(rules, "GET", request_target("/a%2e%2e/"), ""))
|
|
94
|
+
|
|
95
|
+
def test_the_query_string_reaches_the_rules(self):
|
|
96
|
+
from analytics_platform.core import request_target
|
|
97
|
+
rules = compile_rules({"rules": [{"id": "sqli", "action": "block", "query": {"contains": ["union select"]}}]})
|
|
98
|
+
target = request_target("/item", "id=1%20union%20select")
|
|
99
|
+
self.assertEqual(target, "/item?id=1%20union%20select")
|
|
100
|
+
self.assertEqual(match_request(rules, "GET", target, "")["id"], "sqli")
|
|
101
|
+
|
|
102
|
+
def test_the_servers_own_copy_wins(self):
|
|
103
|
+
from analytics_platform.core import request_target
|
|
104
|
+
self.assertEqual(request_target("/x y", "a=1", b"/x%20y?a=1"), "/x%20y?a=1")
|
|
105
|
+
self.assertEqual(request_target("/x y", "a=1", b"/x%20y"), "/x%20y?a=1")
|
|
106
|
+
self.assertEqual(request_target("/caf\u00e9"), "/caf%C3%A9")
|
|
107
|
+
|
|
108
|
+
|
|
77
109
|
if __name__ == "__main__":
|
|
78
110
|
unittest.main()
|
|
@@ -297,3 +297,38 @@ class Detail(unittest.TestCase):
|
|
|
297
297
|
row = mod.disk_rows(["/"], statvfs=lambda _m: Stat())[0]
|
|
298
298
|
self.assertEqual(row["inodes_total"], 262_144)
|
|
299
299
|
self.assertEqual(row["inodes_used"], 62_144)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class ReviewFixes(unittest.TestCase):
|
|
303
|
+
def test_guest_time_is_not_counted_twice(self):
|
|
304
|
+
# guest (column 9) is already inside user: 400 of the 800 busy jiffies.
|
|
305
|
+
before = [1000, 0, 0, 8000, 0, 0, 0, 0, 0, 0]
|
|
306
|
+
after = [1800, 0, 0, 8200, 0, 0, 0, 0, 400, 0]
|
|
307
|
+
self.assertEqual(mod.cpu_percent(mod.cpu_times_of(before), mod.cpu_times_of(after)), 80.0)
|
|
308
|
+
self.assertEqual(mod.cpu_shares(before, after)["cpu_user_pct"], 80.0)
|
|
309
|
+
|
|
310
|
+
def test_the_collectors_off_answer_slows_reporting_and_keeps_one_row(self):
|
|
311
|
+
core = FakeCore(Answer(status=200, body={"v": 1, "enabled": False, "every": 300}))
|
|
312
|
+
checks = Resources(core)
|
|
313
|
+
checks.rows = {"2026-10-20T12:00:00Z": {"minute": "2026-10-20T12:00:00Z"}}
|
|
314
|
+
self.assertGreaterEqual(checks._report(), mod.OFF_EVERY)
|
|
315
|
+
self.assertFalse(checks.enabled)
|
|
316
|
+
checks.last_at -= 120
|
|
317
|
+
checks.sample()
|
|
318
|
+
checks.last_at -= 120
|
|
319
|
+
checks.sample()
|
|
320
|
+
self.assertEqual(len(checks.rows), 1, "only the row that asks again")
|
|
321
|
+
core.answer = Answer()
|
|
322
|
+
self.assertEqual(checks._report(), checks.every)
|
|
323
|
+
self.assertTrue(checks.enabled)
|
|
324
|
+
|
|
325
|
+
def test_close_sends_what_is_buffered(self):
|
|
326
|
+
core = FakeCore()
|
|
327
|
+
checks = Resources(core)
|
|
328
|
+
checks.rows = {"2026-10-20T12:00:00Z": {"minute": "2026-10-20T12:00:00Z"}}
|
|
329
|
+
checks.close()
|
|
330
|
+
self.assertEqual(len(core.sent), 1)
|
|
331
|
+
core.on = False
|
|
332
|
+
checks.rows = {"2026-10-20T12:01:00Z": {"minute": "2026-10-20T12:01:00Z"}}
|
|
333
|
+
checks.close()
|
|
334
|
+
self.assertEqual(len(core.sent), 1, "nothing goes out once the module is switched off")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|