analytics-platform 0.3.8__tar.gz → 0.3.10__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 (55) hide show
  1. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/PKG-INFO +5 -1
  2. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/core.py +31 -9
  3. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/firewall.py +3 -1
  4. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/proxy.py +17 -2
  5. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform.egg-info/PKG-INFO +5 -1
  6. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform.egg-info/SOURCES.txt +2 -0
  7. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/pyproject.toml +7 -1
  8. analytics_platform-0.3.10/tests/test_headers.py +28 -0
  9. analytics_platform-0.3.10/tests/test_proxy_answer.py +27 -0
  10. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/LICENSE +0 -0
  11. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/README.md +0 -0
  12. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/__init__.py +0 -0
  13. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/_env.py +0 -0
  14. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/analytics.py +0 -0
  15. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/audit.py +0 -0
  16. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/calls.py +0 -0
  17. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/celery.py +0 -0
  18. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/challenge.py +0 -0
  19. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/crawlers.py +0 -0
  20. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/django.py +0 -0
  21. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/dramatiq.py +0 -0
  22. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/errors.py +0 -0
  23. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/flags.py +0 -0
  24. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/forms.py +0 -0
  25. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/inventory.py +0 -0
  26. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/maintenance.py +0 -0
  27. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/performance.py +0 -0
  28. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/posture.py +0 -0
  29. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/queries.py +0 -0
  30. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/queues.py +0 -0
  31. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/request_rules.py +0 -0
  32. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/resources.py +0 -0
  33. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/rq.py +0 -0
  34. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform/start.py +0 -0
  35. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform.egg-info/dependency_links.txt +0 -0
  36. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/analytics_platform.egg-info/top_level.txt +0 -0
  37. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/setup.cfg +0 -0
  38. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/tests/test_calls_queues.py +0 -0
  39. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/tests/test_celery.py +0 -0
  40. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/tests/test_challenge.py +0 -0
  41. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/tests/test_checkin.py +0 -0
  42. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/tests/test_crawlers.py +0 -0
  43. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/tests/test_errors.py +0 -0
  44. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/tests/test_firewall.py +0 -0
  45. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/tests/test_hardening.py +0 -0
  46. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/tests/test_limits.py +0 -0
  47. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/tests/test_page_limits.py +0 -0
  48. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/tests/test_proxy.py +0 -0
  49. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/tests/test_queries.py +0 -0
  50. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/tests/test_redis.py +0 -0
  51. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/tests/test_request_rules.py +0 -0
  52. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/tests/test_resources.py +0 -0
  53. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/tests/test_rq_dramatiq.py +0 -0
  54. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/tests/test_start.py +0 -0
  55. {analytics_platform-0.3.8 → analytics_platform-0.3.10}/tests/test_static_late.py +0 -0
@@ -1,9 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: analytics-platform
3
- Version: 0.3.8
3
+ Version: 0.3.10
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
7
+ Project-URL: Homepage, https://sitegoose.com
8
+ Project-URL: Source, https://github.com/StevieC87/Sitegoose-Packages/tree/main/platform-python
9
+ Project-URL: Issues, https://github.com/StevieC87/Sitegoose-Packages/issues
10
+ Project-URL: Security, https://sitegoose.com/security
7
11
  Keywords: firewall,waf,rate-limit,error-monitoring,analytics,server-monitoring,django,flask,fastapi,celery,rq,dramatiq,wsgi,asgi
8
12
  Classifier: Development Status :: 4 - Beta
9
13
  Classifier: Intended Audience :: Developers
@@ -23,7 +23,7 @@ import urllib.error
23
23
  import urllib.parse
24
24
  import urllib.request
25
25
 
26
- VERSION = "0.3.8"
26
+ VERSION = "0.3.10"
27
27
  TIMEOUT = 5
28
28
  MIN_SYNC = 30
29
29
  RETRY = 60
@@ -285,19 +285,24 @@ CLIENTS = ("python", "django")
285
285
 
286
286
 
287
287
 
288
- # A dashboard header is added only when it is well formed: a token name that
289
- # isn't one of the headers that frame the response (a Content-Length or
290
- # Transfer-Encoding of ours would desync it, a Set-Cookie or Content-Type
291
- # would change what it is), and a value of visible ASCII, spaces and tabs,
292
- # so no line break, control character or byte a server rejects gets through.
293
- FRAMING = frozenset(("content-length", "transfer-encoding", "connection", "set-cookie", "content-type"))
294
- _HEADER_NAME = re.compile(r"[A-Za-z][A-Za-z0-9-]{0,63}")
288
+ # A dashboard header is added only when it is one of the nine the dashboard
289
+ # can produce (expresscollect/Analytics/platform.js, headersOf), in any case,
290
+ # with a value of visible ASCII, spaces and tabs, so no line break, control
291
+ # character or byte a server rejects gets through. A list of what's allowed,
292
+ # not of what isn't: whoever got into our servers still couldn't make this
293
+ # app grant another site its users' data (Access-Control-Allow-*), send every
294
+ # page elsewhere (Refresh), log everyone out (Clear-Site-Data) or change what
295
+ # a response is. Shared cases: sdk/protocols/header-cases.json.
296
+ SECURITY_HEADERS = frozenset((
297
+ "strict-transport-security", "x-content-type-options", "x-frame-options", "referrer-policy",
298
+ "permissions-policy", "cross-origin-opener-policy", "content-security-policy",
299
+ "content-security-policy-report-only", "reporting-endpoints"))
295
300
  _HEADER_VALUE = re.compile(r"[\x20-\x7e\t]*")
296
301
 
297
302
 
298
303
  def safe_header(pair):
299
304
  return (isinstance(pair, (list, tuple)) and len(pair) == 2 and isinstance(pair[0], str)
300
- and _HEADER_NAME.fullmatch(pair[0]) is not None and pair[0].lower() not in FRAMING
305
+ and pair[0].lower() in SECURITY_HEADERS
301
306
  and isinstance(pair[1], str) and len(pair[1]) <= 4000 and _HEADER_VALUE.fullmatch(pair[1]) is not None)
302
307
 
303
308
  class Core:
@@ -442,6 +447,23 @@ def header(headers, name):
442
447
  _PATH_SAFE = "/:@!$&'()*+,;=-._~"
443
448
 
444
449
 
450
+ _ABSOLUTE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://[^/?#]*")
451
+
452
+
453
+ def origin_form(target):
454
+ """The request target as the app routes it: an absolute-form one ("GET
455
+ http://host/admin", sent as if to a proxy) is routed by its path, so it is
456
+ cut to "/admin"; matched as written it began "http:" and missed every rule
457
+ on "/admin". "*" and a target already "/..." are kept as they are."""
458
+ text = str(target or "")
459
+ if not text or text.startswith("/") or text == "*":
460
+ return text
461
+ rest, count = _ABSOLUTE.subn("", text, count=1)
462
+ if not count:
463
+ return text
464
+ return rest if rest.startswith("/") else "/" + rest
465
+
466
+
445
467
  def request_target(path, query="", raw=None):
446
468
  """The request target as the visitor sent it, query string and all, which
447
469
  is what the request rules match (sdk/protocols/firewall.md): the Node and
@@ -19,7 +19,7 @@ import time
19
19
  from datetime import datetime, timezone
20
20
 
21
21
  from . import challenge as browser_check
22
- from .core import Periodic, RETRY, header
22
+ from .core import Periodic, RETRY, header, origin_form
23
23
  from .request_rules import MAX_TEXT, _decode_once, compile_limits, compile_rules, match_limits, match_request, normalize_target, prepare
24
24
 
25
25
  PROTOCOL = 1
@@ -572,6 +572,7 @@ class Firewall:
572
572
  request's headers) are for the browser check: rule "challenge",
573
573
  status 503, answered with challenge_page()."""
574
574
  allow = {"action": "allow", "rule": "", "status": 200}
575
+ path = origin_form(path)
575
576
  rules = self.rules
576
577
  if not rules or not rules["enabled"]:
577
578
  return allow
@@ -694,6 +695,7 @@ class Firewall:
694
695
  beacons are never rate-limited, checked or matched against request
695
696
  rules. -> the same shape as check()."""
696
697
  allow = {"action": "allow", "rule": "", "status": 200}
698
+ path = origin_form(path)
697
699
  rules = self.rules
698
700
  if not rules or not rules["enabled"]:
699
701
  return allow
@@ -49,6 +49,21 @@ PASS_HEADERS = ("content-type", "user-agent", "accept-language", "referer", "ori
49
49
  "sec-fetch-site", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform")
50
50
  SAFE_PATH = re.compile(r"[A-Za-z0-9/_.-]{1,120}\Z")
51
51
  FILE_PATH = re.compile(r"v(\d+(?:\.\d+\.\d+)?)/([a-z]+\.js)\Z")
52
+ # What a collector answer may be served as from the site's own domain. Its
53
+ # type comes from the collector, so anything but these three becomes plain
54
+ # text, and nosniff stops a browser guessing: a server that is no longer ours
55
+ # still can't answer <prefix>/api/... with a page that runs as the customer's
56
+ # own site. Shared cases: sdk/protocols/proxy-answer-cases.json.
57
+ ANSWER_TYPES = frozenset(("application/json", "text/plain", "image/webp"))
58
+ _VISIBLE = re.compile(r"[\x20-\x7e]*")
59
+
60
+
61
+ def answer_type(value):
62
+ value = value if isinstance(value, str) else ""
63
+ kind = value.split(";", 1)[0].strip().lower()
64
+ return value if kind in ANSWER_TYPES and _VISIBLE.fullmatch(value) else "text/plain; charset=utf-8"
65
+
66
+
52
67
  NOT_FOUND = (404, [("Content-Type", "text/plain; charset=utf-8")], b"Not found")
53
68
 
54
69
 
@@ -236,8 +251,8 @@ class Proxy:
236
251
 
237
252
  @staticmethod
238
253
  def _answer_headers(headers):
239
- out = []
240
- for name in ("Content-Type", "Cache-Control", "Retry-After"):
254
+ out = [("Content-Type", answer_type(headers.get("Content-Type"))), ("X-Content-Type-Options", "nosniff")]
255
+ for name in ("Cache-Control", "Retry-After"):
241
256
  value = headers.get(name)
242
257
  if value:
243
258
  out.append((name, value))
@@ -1,9 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: analytics-platform
3
- Version: 0.3.8
3
+ Version: 0.3.10
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
7
+ Project-URL: Homepage, https://sitegoose.com
8
+ Project-URL: Source, https://github.com/StevieC87/Sitegoose-Packages/tree/main/platform-python
9
+ Project-URL: Issues, https://github.com/StevieC87/Sitegoose-Packages/issues
10
+ Project-URL: Security, https://sitegoose.com/security
7
11
  Keywords: firewall,waf,rate-limit,error-monitoring,analytics,server-monitoring,django,flask,fastapi,celery,rq,dramatiq,wsgi,asgi
8
12
  Classifier: Development Status :: 4 - Beta
9
13
  Classifier: Intended Audience :: Developers
@@ -39,9 +39,11 @@ tests/test_crawlers.py
39
39
  tests/test_errors.py
40
40
  tests/test_firewall.py
41
41
  tests/test_hardening.py
42
+ tests/test_headers.py
42
43
  tests/test_limits.py
43
44
  tests/test_page_limits.py
44
45
  tests/test_proxy.py
46
+ tests/test_proxy_answer.py
45
47
  tests/test_queries.py
46
48
  tests/test_redis.py
47
49
  tests/test_request_rules.py
@@ -2,9 +2,15 @@
2
2
  requires = ["setuptools>=77"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
+ [project.urls]
6
+ Homepage = "https://sitegoose.com"
7
+ Source = "https://github.com/StevieC87/Sitegoose-Packages/tree/main/platform-python"
8
+ Issues = "https://github.com/StevieC87/Sitegoose-Packages/issues"
9
+ Security = "https://sitegoose.com/security"
10
+
5
11
  [project]
6
12
  name = "analytics-platform"
7
- version = "0.3.8"
13
+ version = "0.3.10"
8
14
  description = "Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard."
9
15
  readme = "README.md"
10
16
  requires-python = ">=3.8"
@@ -0,0 +1,28 @@
1
+ """The security headers taken from the platform config, against the cases every
2
+ client shares: sdk/protocols/header-cases.json. Only the nine the dashboard can
3
+ produce get through, so all the packages add the same ones."""
4
+ import json
5
+ import os
6
+ import unittest
7
+
8
+ from analytics_platform.core import safe_header
9
+
10
+ CASES = os.path.join(os.path.dirname(__file__), "..", "..", "protocols", "header-cases.json")
11
+
12
+
13
+ class HeaderCases(unittest.TestCase):
14
+ def test_shared_cases(self):
15
+ with open(CASES, encoding="utf-8") as f:
16
+ cases = json.load(f)["cases"]
17
+ for case in cases:
18
+ value = case["value"] * case.get("repeat", 1)
19
+ with self.subTest(name=case["name"], value=case["value"][:40], repeat=case.get("repeat")):
20
+ self.assertEqual(safe_header([case["name"], value]), case["ok"])
21
+
22
+ def test_only_pairs_of_strings(self):
23
+ for pair in (None, "X-Frame-Options", ["X-Frame-Options"], ["X-Frame-Options", 1], ["X-Frame-Options", "DENY", "x"]):
24
+ self.assertFalse(safe_header(pair))
25
+
26
+
27
+ if __name__ == "__main__":
28
+ unittest.main()
@@ -0,0 +1,27 @@
1
+ """The type a collector answer is served with from the site's own domain,
2
+ against the cases every client shares: sdk/protocols/proxy-answer-cases.json."""
3
+ import json
4
+ import os
5
+ import unittest
6
+
7
+ from analytics_platform.proxy import Proxy, answer_type
8
+
9
+ CASES = os.path.join(os.path.dirname(__file__), "..", "..", "protocols", "proxy-answer-cases.json")
10
+
11
+
12
+ class ProxyAnswerCases(unittest.TestCase):
13
+ def test_shared_cases(self):
14
+ with open(CASES, encoding="utf-8") as f:
15
+ cases = json.load(f)["cases"]
16
+ for case in cases:
17
+ with self.subTest(type=case["type"]):
18
+ self.assertEqual(answer_type(case["type"]), case["served"])
19
+
20
+ def test_nosniff_and_only_cache_and_retry(self):
21
+ upstream = {"Content-Type": "text/html", "Cache-Control": "no-store", "Set-Cookie": "a=b", "Refresh": "0;url=x"}
22
+ self.assertEqual(Proxy._answer_headers(upstream), [
23
+ ("Content-Type", "text/plain; charset=utf-8"), ("X-Content-Type-Options", "nosniff"), ("Cache-Control", "no-store")])
24
+
25
+
26
+ if __name__ == "__main__":
27
+ unittest.main()