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.
Files changed (31) hide show
  1. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/PKG-INFO +2 -2
  2. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/README.md +1 -1
  3. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/__init__.py +18 -4
  4. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/core.py +26 -1
  5. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/django.py +3 -1
  6. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/firewall.py +3 -2
  7. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/proxy.py +1 -0
  8. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/request_rules.py +96 -17
  9. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/resources.py +22 -3
  10. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform.egg-info/PKG-INFO +2 -2
  11. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform.egg-info/SOURCES.txt +1 -0
  12. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/pyproject.toml +1 -1
  13. analytics_platform-0.2.2/tests/test_firewall.py +56 -0
  14. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/tests/test_request_rules.py +32 -0
  15. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/tests/test_resources.py +35 -0
  16. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/LICENSE +0 -0
  17. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/analytics.py +0 -0
  18. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/audit.py +0 -0
  19. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/crawlers.py +0 -0
  20. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/errors.py +0 -0
  21. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/flags.py +0 -0
  22. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/forms.py +0 -0
  23. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/inventory.py +0 -0
  24. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/maintenance.py +0 -0
  25. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/performance.py +0 -0
  26. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform/posture.py +0 -0
  27. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform.egg-info/dependency_links.txt +0 -0
  28. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/analytics_platform.egg-info/top_level.txt +0 -0
  29. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/setup.cfg +0 -0
  30. {analytics_platform-0.2.1 → analytics_platform-0.2.2}/tests/test_crawlers.py +0 -0
  31. {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.1
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 → Settings → Server package) without a
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 → Settings → Server package) without a
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.get("PATH_INFO", ""))
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
- blocked = self.platform.blocked(client[0] or "", headers, scope.get("method", ""), scope.get("path", ""))
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.1.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
- blocked = self.platform.blocked(ip, headers, request.method, request.path)
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 site's other servers.
296
- self._ban_here(str(ip), rules["ban"])
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: a quantifier on a group that itself repeats ("(a+)+") takes
33
- exponential time on input that nearly matches. Backreferences and lookbehind
34
- are refused for the same reason."""
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
- open_at = []
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
- open_at.append(i)
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 open_at:
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
- start = open_at.pop()
59
- nxt = pattern[i + 1] if i + 1 < len(pattern) else ""
60
- if nxt in ("*", "+", "{"):
61
- inside = pattern[start + 1:i]
62
- if re.search(r"(^|[^\\])[*+]", inside) or re.search(r"(^|[^\\])\{[0-9]", inside):
63
- return False
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
- return not open_at
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
- raw_query = ("" if mark == -1 else target[mark + 1:])[:MAX_TEXT].lower()
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
- decoded_query = _decode_once(raw_query)[:MAX_TEXT]
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 = sum(fields)
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 = sum(after) - sum(before)
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
- return self.every if self.flush() else RETRY_EVERY
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.1
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 → Settings → Server package) without a
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
@@ -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.1"
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")