analytics-platform 0.3.7__tar.gz → 0.3.9__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.3.7/analytics_platform.egg-info → analytics_platform-0.3.9}/PKG-INFO +54 -20
- analytics_platform-0.3.7/PKG-INFO → analytics_platform-0.3.9/README.md +49 -45
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/__init__.py +17 -3
- analytics_platform-0.3.9/analytics_platform/_env.py +71 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/core.py +14 -9
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/django.py +14 -6
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/proxy.py +17 -2
- analytics_platform-0.3.9/analytics_platform/start.py +118 -0
- analytics_platform-0.3.7/README.md → analytics_platform-0.3.9/analytics_platform.egg-info/PKG-INFO +79 -19
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform.egg-info/SOURCES.txt +5 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/pyproject.toml +7 -1
- analytics_platform-0.3.9/tests/test_headers.py +28 -0
- analytics_platform-0.3.9/tests/test_proxy_answer.py +27 -0
- analytics_platform-0.3.9/tests/test_start.py +156 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/LICENSE +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/analytics.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/audit.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/calls.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/celery.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/challenge.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/crawlers.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/dramatiq.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/errors.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/firewall.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/flags.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/forms.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/inventory.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/maintenance.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/performance.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/posture.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/queries.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/queues.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/request_rules.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/resources.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform/rq.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform.egg-info/dependency_links.txt +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform.egg-info/top_level.txt +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/setup.cfg +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_calls_queues.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_celery.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_challenge.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_checkin.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_crawlers.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_errors.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_firewall.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_hardening.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_limits.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_page_limits.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_proxy.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_queries.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_redis.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_request_rules.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_resources.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/tests/test_rq_dramatiq.py +0 -0
- {analytics_platform-0.3.7 → analytics_platform-0.3.9}/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.
|
|
3
|
+
Version: 0.3.9
|
|
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
|
|
@@ -35,7 +39,42 @@ dependencies; Python 3.8 or later.
|
|
|
35
39
|
pip install analytics-platform
|
|
36
40
|
```
|
|
37
41
|
|
|
38
|
-
|
|
42
|
+
Put your keys in `.env` (site settings has all three, ready to copy), or in
|
|
43
|
+
your host's environment:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
ANALYTICS_SITE_ID=YOUR_SITE_ID
|
|
47
|
+
ANALYTICS_SITE_SECRET=YOUR_SITE_SECRET
|
|
48
|
+
ANALYTICS_ENDPOINT=https://YOUR_COLLECTOR_URL
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The package reads only the `ANALYTICS_*` lines of the `.env` in the folder the
|
|
52
|
+
server starts in (Django: beside `manage.py`), and never writes them into
|
|
53
|
+
`os.environ`, so your own `.env` loading is unaffected. Behind Cloudflare, add
|
|
54
|
+
`ANALYTICS_TRUST_PROXY=cloudflare`; behind Nginx or a load balancer,
|
|
55
|
+
`ANALYTICS_TRUST_PROXY=1`. Several services on one site? Give each
|
|
56
|
+
`ANALYTICS_SERVER_NAME=api` (or `worker`, and so on).
|
|
57
|
+
|
|
58
|
+
## Flask and FastAPI: one line
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from analytics_platform.start import platform
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Put it near the top of the module that makes your app. On the app's first
|
|
65
|
+
request, the package puts itself in front of it: Flask through
|
|
66
|
+
`init_flask(app)`, and FastAPI or any Starlette app through its middleware, as
|
|
67
|
+
the outermost of yours. It works whether the import is above or below
|
|
68
|
+
`app = Flask(...)`. An app that already has the platform by hand gets nothing
|
|
69
|
+
added a second time, and a FastAPI app mounted inside another isn't counted
|
|
70
|
+
twice.
|
|
71
|
+
|
|
72
|
+
Use the same import wherever you call `platform.track()` and the rest: it's
|
|
73
|
+
one platform for the whole process. It's made on first use, not on import, so
|
|
74
|
+
under Gunicorn or uWSGI with `--preload` its background threads start in each
|
|
75
|
+
worker, not in the master.
|
|
76
|
+
|
|
77
|
+
## Django: one line in settings
|
|
39
78
|
|
|
40
79
|
```python
|
|
41
80
|
# settings.py
|
|
@@ -43,36 +82,31 @@ MIDDLEWARE = [
|
|
|
43
82
|
"analytics_platform.django.PlatformMiddleware", # first
|
|
44
83
|
# ...
|
|
45
84
|
]
|
|
85
|
+
```
|
|
46
86
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
# "trust_proxy": "cloudflare", # every request through Cloudflare
|
|
53
|
-
}
|
|
87
|
+
That's all with the keys in `.env` or the environment. Options in code go in
|
|
88
|
+
`ANALYTICS_PLATFORM`, and win over the environment:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
ANALYTICS_PLATFORM = {"trust_proxy": 1, "errors": {"capture_logging": True}}
|
|
54
92
|
```
|
|
55
93
|
|
|
56
94
|
In your code: `from analytics_platform.django import get_platform`. The
|
|
57
95
|
package tells the collector it is a Django site (`analytics-platform-django/…`),
|
|
58
96
|
so attack patterns written for Django reach it.
|
|
59
97
|
|
|
60
|
-
##
|
|
98
|
+
## Setting it up in code instead
|
|
99
|
+
|
|
100
|
+
For options in code with Flask, FastAPI or another app (use this or the line
|
|
101
|
+
above, not both). Any key left out is read from `ANALYTICS_*`:
|
|
61
102
|
|
|
62
103
|
```python
|
|
63
104
|
from analytics_platform import create_platform, PlatformMiddleware
|
|
64
105
|
|
|
65
|
-
platform = create_platform(
|
|
66
|
-
endpoint="https://YOUR_COLLECTOR_URL")
|
|
67
|
-
app.add_middleware(PlatformMiddleware, platform=platform)
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
## Flask
|
|
106
|
+
platform = create_platform(errors={"capture_logging": True})
|
|
71
107
|
|
|
72
|
-
|
|
73
|
-
platform
|
|
74
|
-
endpoint="https://YOUR_COLLECTOR_URL")
|
|
75
|
-
platform.init_flask(app)
|
|
108
|
+
app.add_middleware(PlatformMiddleware, platform=platform) # FastAPI / Starlette
|
|
109
|
+
platform.init_flask(app) # Flask
|
|
76
110
|
```
|
|
77
111
|
|
|
78
112
|
Other WSGI apps: `app = platform.wsgi(app)`; other ASGI apps: `app = platform.asgi(app)`.
|
|
@@ -1,29 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: analytics-platform
|
|
3
|
-
Version: 0.3.7
|
|
4
|
-
Summary: Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard.
|
|
5
|
-
Author: steviec87
|
|
6
|
-
License-Expression: MIT
|
|
7
|
-
Keywords: firewall,waf,rate-limit,error-monitoring,analytics,server-monitoring,django,flask,fastapi,celery,rq,dramatiq,wsgi,asgi
|
|
8
|
-
Classifier: Development Status :: 4 - Beta
|
|
9
|
-
Classifier: Intended Audience :: Developers
|
|
10
|
-
Classifier: Intended Audience :: System Administrators
|
|
11
|
-
Classifier: Operating System :: OS Independent
|
|
12
|
-
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
-
Classifier: Framework :: Django
|
|
16
|
-
Classifier: Framework :: Flask
|
|
17
|
-
Classifier: Framework :: FastAPI
|
|
18
|
-
Classifier: Topic :: Internet :: WWW/HTTP
|
|
19
|
-
Classifier: Topic :: Internet :: Log Analysis
|
|
20
|
-
Classifier: Topic :: Security
|
|
21
|
-
Classifier: Topic :: System :: Monitoring
|
|
22
|
-
Requires-Python: >=3.8
|
|
23
|
-
Description-Content-Type: text/markdown
|
|
24
|
-
License-File: LICENSE
|
|
25
|
-
Dynamic: license-file
|
|
26
|
-
|
|
27
1
|
# analytics-platform (Python)
|
|
28
2
|
|
|
29
3
|
One package for your Python server, run from your analytics dashboard: a
|
|
@@ -35,7 +9,42 @@ dependencies; Python 3.8 or later.
|
|
|
35
9
|
pip install analytics-platform
|
|
36
10
|
```
|
|
37
11
|
|
|
38
|
-
|
|
12
|
+
Put your keys in `.env` (site settings has all three, ready to copy), or in
|
|
13
|
+
your host's environment:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
ANALYTICS_SITE_ID=YOUR_SITE_ID
|
|
17
|
+
ANALYTICS_SITE_SECRET=YOUR_SITE_SECRET
|
|
18
|
+
ANALYTICS_ENDPOINT=https://YOUR_COLLECTOR_URL
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The package reads only the `ANALYTICS_*` lines of the `.env` in the folder the
|
|
22
|
+
server starts in (Django: beside `manage.py`), and never writes them into
|
|
23
|
+
`os.environ`, so your own `.env` loading is unaffected. Behind Cloudflare, add
|
|
24
|
+
`ANALYTICS_TRUST_PROXY=cloudflare`; behind Nginx or a load balancer,
|
|
25
|
+
`ANALYTICS_TRUST_PROXY=1`. Several services on one site? Give each
|
|
26
|
+
`ANALYTICS_SERVER_NAME=api` (or `worker`, and so on).
|
|
27
|
+
|
|
28
|
+
## Flask and FastAPI: one line
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from analytics_platform.start import platform
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Put it near the top of the module that makes your app. On the app's first
|
|
35
|
+
request, the package puts itself in front of it: Flask through
|
|
36
|
+
`init_flask(app)`, and FastAPI or any Starlette app through its middleware, as
|
|
37
|
+
the outermost of yours. It works whether the import is above or below
|
|
38
|
+
`app = Flask(...)`. An app that already has the platform by hand gets nothing
|
|
39
|
+
added a second time, and a FastAPI app mounted inside another isn't counted
|
|
40
|
+
twice.
|
|
41
|
+
|
|
42
|
+
Use the same import wherever you call `platform.track()` and the rest: it's
|
|
43
|
+
one platform for the whole process. It's made on first use, not on import, so
|
|
44
|
+
under Gunicorn or uWSGI with `--preload` its background threads start in each
|
|
45
|
+
worker, not in the master.
|
|
46
|
+
|
|
47
|
+
## Django: one line in settings
|
|
39
48
|
|
|
40
49
|
```python
|
|
41
50
|
# settings.py
|
|
@@ -43,36 +52,31 @@ MIDDLEWARE = [
|
|
|
43
52
|
"analytics_platform.django.PlatformMiddleware", # first
|
|
44
53
|
# ...
|
|
45
54
|
]
|
|
55
|
+
```
|
|
46
56
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
# "trust_proxy": "cloudflare", # every request through Cloudflare
|
|
53
|
-
}
|
|
57
|
+
That's all with the keys in `.env` or the environment. Options in code go in
|
|
58
|
+
`ANALYTICS_PLATFORM`, and win over the environment:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
ANALYTICS_PLATFORM = {"trust_proxy": 1, "errors": {"capture_logging": True}}
|
|
54
62
|
```
|
|
55
63
|
|
|
56
64
|
In your code: `from analytics_platform.django import get_platform`. The
|
|
57
65
|
package tells the collector it is a Django site (`analytics-platform-django/…`),
|
|
58
66
|
so attack patterns written for Django reach it.
|
|
59
67
|
|
|
60
|
-
##
|
|
68
|
+
## Setting it up in code instead
|
|
69
|
+
|
|
70
|
+
For options in code with Flask, FastAPI or another app (use this or the line
|
|
71
|
+
above, not both). Any key left out is read from `ANALYTICS_*`:
|
|
61
72
|
|
|
62
73
|
```python
|
|
63
74
|
from analytics_platform import create_platform, PlatformMiddleware
|
|
64
75
|
|
|
65
|
-
platform = create_platform(
|
|
66
|
-
endpoint="https://YOUR_COLLECTOR_URL")
|
|
67
|
-
app.add_middleware(PlatformMiddleware, platform=platform)
|
|
68
|
-
```
|
|
76
|
+
platform = create_platform(errors={"capture_logging": True})
|
|
69
77
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
```python
|
|
73
|
-
platform = create_platform(site_id="YOUR_SITE_ID", secret=os.environ["ANALYTICS_SITE_SECRET"],
|
|
74
|
-
endpoint="https://YOUR_COLLECTOR_URL")
|
|
75
|
-
platform.init_flask(app)
|
|
78
|
+
app.add_middleware(PlatformMiddleware, platform=platform) # FastAPI / Starlette
|
|
79
|
+
platform.init_flask(app) # Flask
|
|
76
80
|
```
|
|
77
81
|
|
|
78
82
|
Other WSGI apps: `app = platform.wsgi(app)`; other ASGI apps: `app = platform.asgi(app)`.
|
|
@@ -20,6 +20,7 @@ import http
|
|
|
20
20
|
import time
|
|
21
21
|
import weakref
|
|
22
22
|
|
|
23
|
+
from . import _env
|
|
23
24
|
from .core import Core, EXIT_DEADLINE, SESSION_HEADER, VERSION, begin_exit, header, request_target, within
|
|
24
25
|
from .errors import add_breadcrumb, current_request, current_trail
|
|
25
26
|
from .queries import current_queries
|
|
@@ -77,7 +78,8 @@ MODULES = ("proxy", "firewall", "errors", "analytics", "performance", "crawlers"
|
|
|
77
78
|
|
|
78
79
|
class Platform:
|
|
79
80
|
"""site_id, secret (site settings; keep it on the server), endpoint (the
|
|
80
|
-
collector's URL)
|
|
81
|
+
collector's URL), each read from ANALYTICS_SITE_ID, ANALYTICS_SITE_SECRET and
|
|
82
|
+
ANALYTICS_ENDPOINT when left out; trust_proxy (ANALYTICS_TRUST_PROXY) (how to find the visitor's address: None
|
|
81
83
|
uses the connection's, a number of proxies in X-Forwarded-For,
|
|
82
84
|
"cloudflare"); debug (print send problems); per module True / False / {options}.
|
|
83
85
|
client: the name this package goes by to the collector ("python", or
|
|
@@ -86,14 +88,20 @@ class Platform:
|
|
|
86
88
|
keep Server speed and leave database queries, outgoing calls or job
|
|
87
89
|
queues out of it."""
|
|
88
90
|
|
|
89
|
-
def __init__(self, site_id, secret, endpoint, trust_proxy=None, debug=False, firewall=True, errors=True, analytics=True,
|
|
91
|
+
def __init__(self, site_id=None, secret=None, endpoint=None, trust_proxy=None, debug=False, firewall=True, errors=True, analytics=True,
|
|
90
92
|
performance=True, crawlers=True, forms=True, headers=True, audit=True, flags=True, maintenance=True,
|
|
91
93
|
inventory=True,
|
|
92
94
|
posture=True, resources=True, proxy=True, server_name=None, client="python", queries=True,
|
|
93
95
|
calls=True, queues=True):
|
|
96
|
+
# Each of these left out is read from ANALYTICS_* in the environment (_env.py).
|
|
97
|
+
given = _env.from_environment({"site_id": site_id, "secret": secret, "endpoint": endpoint,
|
|
98
|
+
"trust_proxy": trust_proxy, "server_name": server_name})
|
|
99
|
+
site_id, secret, endpoint = given["site_id"], given["secret"], given["endpoint"]
|
|
100
|
+
trust_proxy, server_name = given["trust_proxy"], given["server_name"]
|
|
94
101
|
self.core = Core(site_id, secret, endpoint, debug, server_name=server_name, client=client)
|
|
95
102
|
if not self.core.valid:
|
|
96
|
-
print("[analytics-platform] site_id, secret and endpoint are required
|
|
103
|
+
print("[analytics-platform] site_id, secret and endpoint are required (or ANALYTICS_SITE_ID, ANALYTICS_SITE_SECRET "
|
|
104
|
+
"and ANALYTICS_ENDPOINT in the environment); nothing is sent and every request passes")
|
|
97
105
|
settings = {"firewall": firewall, "errors": errors, "analytics": analytics, "performance": performance,
|
|
98
106
|
"crawlers": crawlers, "forms": forms,
|
|
99
107
|
"headers": headers, "audit": audit, "flags": flags, "maintenance": maintenance, "inventory": inventory,
|
|
@@ -577,6 +585,12 @@ class Platform:
|
|
|
577
585
|
|
|
578
586
|
def init_flask(self, app):
|
|
579
587
|
"""Flask: the firewall in front of the app, and its request errors."""
|
|
588
|
+
# Once per app, by hand or by analytics_platform.start.
|
|
589
|
+
extensions = getattr(app, "extensions", None)
|
|
590
|
+
if isinstance(extensions, dict):
|
|
591
|
+
if "analytics_platform" in extensions:
|
|
592
|
+
return app
|
|
593
|
+
extensions["analytics_platform"] = self
|
|
580
594
|
app.wsgi_app = self.wsgi(app.wsgi_app, timing=False)
|
|
581
595
|
from flask import g, got_request_exception, request
|
|
582
596
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""The options read from ANALYTICS_* when they aren't passed, as the Node and
|
|
2
|
+
Go packages do: ANALYTICS_SITE_ID, ANALYTICS_SITE_SECRET, ANALYTICS_ENDPOINT,
|
|
3
|
+
and optionally ANALYTICS_TRUST_PROXY ("cloudflare" or a number of proxies)
|
|
4
|
+
and ANALYTICS_SERVER_NAME. And the ANALYTICS_* lines of a .env file, for
|
|
5
|
+
analytics_platform.start and Django: read, never written to os.environ, so
|
|
6
|
+
whatever loads the app's own .env files still sets them as it would."""
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
_LINE = re.compile(r"^\s*(?:export\s+)?(ANALYTICS_[A-Z0-9_]+)\s*=\s*(.*)$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def options_from(env):
|
|
14
|
+
"""{site_id, secret, endpoint, trust_proxy, server_name} found in env."""
|
|
15
|
+
def read(name):
|
|
16
|
+
value = env.get(name)
|
|
17
|
+
return value.strip() if isinstance(value, str) and value.strip() else None
|
|
18
|
+
|
|
19
|
+
found = {}
|
|
20
|
+
for option, name in (("site_id", "ANALYTICS_SITE_ID"), ("secret", "ANALYTICS_SITE_SECRET"),
|
|
21
|
+
("endpoint", "ANALYTICS_ENDPOINT"), ("server_name", "ANALYTICS_SERVER_NAME")):
|
|
22
|
+
if read(name):
|
|
23
|
+
found[option] = read(name)
|
|
24
|
+
if "endpoint" not in found and read("ANALYTICS_FIREWALL_ENDPOINT"):
|
|
25
|
+
found["endpoint"] = read("ANALYTICS_FIREWALL_ENDPOINT")
|
|
26
|
+
trust = read("ANALYTICS_TRUST_PROXY")
|
|
27
|
+
if trust:
|
|
28
|
+
if trust.lower() == "cloudflare":
|
|
29
|
+
found["trust_proxy"] = "cloudflare"
|
|
30
|
+
elif trust.lower() == "true":
|
|
31
|
+
found["trust_proxy"] = 1
|
|
32
|
+
elif trust.isdigit() and int(trust) > 0:
|
|
33
|
+
found["trust_proxy"] = int(trust)
|
|
34
|
+
return found
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_dotenv(path):
|
|
38
|
+
"""The ANALYTICS_* lines of a .env file as {name: value}; {} without one.
|
|
39
|
+
Not a dotenv: no expansion, and every other line is ignored."""
|
|
40
|
+
try:
|
|
41
|
+
with open(path, encoding="utf-8") as handle:
|
|
42
|
+
text = handle.read()
|
|
43
|
+
except (OSError, UnicodeDecodeError):
|
|
44
|
+
return {}
|
|
45
|
+
found = {}
|
|
46
|
+
for line in text.splitlines():
|
|
47
|
+
match = _LINE.match(line)
|
|
48
|
+
if not match:
|
|
49
|
+
continue
|
|
50
|
+
value = match.group(2).strip()
|
|
51
|
+
quote = value[:1]
|
|
52
|
+
if quote in ("'", '"') and value.find(quote, 1) > 0:
|
|
53
|
+
value = value[1:value.find(quote, 1)]
|
|
54
|
+
else:
|
|
55
|
+
value = re.sub(r"\s+#.*$", "", value)
|
|
56
|
+
found[match.group(1)] = value
|
|
57
|
+
return found
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def from_environment(options, dotenv=None):
|
|
61
|
+
"""options with what's missing filled from os.environ, then (when given)
|
|
62
|
+
from a .env file's ANALYTICS_* lines."""
|
|
63
|
+
filled = dict(options)
|
|
64
|
+
sources = [options_from(os.environ)]
|
|
65
|
+
if dotenv:
|
|
66
|
+
sources.append(options_from(read_dotenv(dotenv)))
|
|
67
|
+
for source in sources:
|
|
68
|
+
for key, value in source.items():
|
|
69
|
+
if filled.get(key) is None:
|
|
70
|
+
filled[key] = value
|
|
71
|
+
return filled
|
|
@@ -23,7 +23,7 @@ import urllib.error
|
|
|
23
23
|
import urllib.parse
|
|
24
24
|
import urllib.request
|
|
25
25
|
|
|
26
|
-
VERSION = "0.3.
|
|
26
|
+
VERSION = "0.3.9"
|
|
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
|
|
289
|
-
#
|
|
290
|
-
#
|
|
291
|
-
#
|
|
292
|
-
#
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
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:
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
"""Django: add the middleware first in MIDDLEWARE
|
|
1
|
+
"""Django: add the middleware first in MIDDLEWARE. That's all when the keys
|
|
2
|
+
are in the environment or in the project's .env (beside manage.py):
|
|
3
|
+
ANALYTICS_SITE_ID, ANALYTICS_SITE_SECRET and ANALYTICS_ENDPOINT.
|
|
2
4
|
|
|
3
5
|
MIDDLEWARE = ["analytics_platform.django.PlatformMiddleware", ...]
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
6
|
+
|
|
7
|
+
Options in code go in settings, and win over the environment:
|
|
8
|
+
|
|
9
|
+
ANALYTICS_PLATFORM = {"trust_proxy": 1, "errors": {"capture_logging": True}}
|
|
9
10
|
|
|
10
11
|
In your code, get_platform() is the same platform: get_platform().track(...).
|
|
11
12
|
The visitor's address is REMOTE_ADDR unless you set trust_proxy. The package
|
|
@@ -13,11 +14,13 @@ says it is Django (analytics-platform-django/<version>), so request rules
|
|
|
13
14
|
aimed at Django sites reach it. Every connection's queries are timed for
|
|
14
15
|
Server speed, under the request that ran them ("queries": False leaves them out).
|
|
15
16
|
"""
|
|
17
|
+
import os
|
|
16
18
|
import time
|
|
17
19
|
|
|
18
20
|
from django.conf import settings
|
|
19
21
|
from django.http import HttpResponse
|
|
20
22
|
|
|
23
|
+
from . import _env
|
|
21
24
|
from . import SESSION_HEADER, Platform, _inflight_end, _inflight_start, _queries_end, _queries_start
|
|
22
25
|
from .errors import current_request, current_trail
|
|
23
26
|
|
|
@@ -27,8 +30,13 @@ _platform = None
|
|
|
27
30
|
def get_platform():
|
|
28
31
|
global _platform
|
|
29
32
|
if _platform is None:
|
|
33
|
+
# Settings first, then ANALYTICS_* in the environment, then the
|
|
34
|
+
# ANALYTICS_* lines of the project's .env (beside manage.py), which is
|
|
35
|
+
# read, never loaded into the environment.
|
|
30
36
|
options = dict(getattr(settings, "ANALYTICS_PLATFORM", {}))
|
|
31
37
|
options.setdefault("client", "django")
|
|
38
|
+
base = getattr(settings, "BASE_DIR", None) or os.getcwd()
|
|
39
|
+
options = _env.from_environment(options, os.path.join(str(base), ".env"))
|
|
32
40
|
_platform = Platform(**options)
|
|
33
41
|
_connect_login_failed()
|
|
34
42
|
_watch_queries(_platform)
|
|
@@ -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 ("
|
|
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))
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""The one-line setup. Near the top of the module that makes your app:
|
|
2
|
+
|
|
3
|
+
from analytics_platform.start import platform
|
|
4
|
+
|
|
5
|
+
It reads ANALYTICS_SITE_ID, ANALYTICS_SITE_SECRET and ANALYTICS_ENDPOINT from
|
|
6
|
+
the environment, or else from the ANALYTICS_* lines of the .env in the folder
|
|
7
|
+
the server starts in (read, never written to os.environ).
|
|
8
|
+
|
|
9
|
+
Flask and FastAPI (any Starlette app): that's all. On an app's first request
|
|
10
|
+
it puts itself in front, as platform.init_flask(app) and
|
|
11
|
+
app.add_middleware(PlatformMiddleware, platform=platform) do by hand, so it
|
|
12
|
+
doesn't matter whether this import comes before or after the app is made. An
|
|
13
|
+
app that already has either by hand gets nothing added a second time, and a
|
|
14
|
+
Starlette app mounted inside another isn't counted twice.
|
|
15
|
+
|
|
16
|
+
Django: add "analytics_platform.django.PlatformMiddleware" first in
|
|
17
|
+
MIDDLEWARE instead, and use get_platform() from there.
|
|
18
|
+
|
|
19
|
+
The platform is made on first use (the first request, or the first
|
|
20
|
+
platform.track()), not on import: under Gunicorn or uWSGI with the app loaded
|
|
21
|
+
before the workers fork, its background threads then start in each worker,
|
|
22
|
+
not in the master. Use the same import wherever you call platform.track() and
|
|
23
|
+
the rest: it's one platform for the process.
|
|
24
|
+
"""
|
|
25
|
+
import os
|
|
26
|
+
import threading
|
|
27
|
+
|
|
28
|
+
from . import Platform, PlatformMiddleware, _env
|
|
29
|
+
|
|
30
|
+
_lock = threading.Lock()
|
|
31
|
+
_made = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _platform():
|
|
35
|
+
global _made
|
|
36
|
+
if _made is None:
|
|
37
|
+
with _lock:
|
|
38
|
+
if _made is None:
|
|
39
|
+
_made = Platform(**_env.from_environment({}, os.path.join(os.getcwd(), ".env")))
|
|
40
|
+
return _made
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _Platform:
|
|
44
|
+
"""Stands for the platform until its first use, then hands every
|
|
45
|
+
attribute to it."""
|
|
46
|
+
|
|
47
|
+
def __getattr__(self, name):
|
|
48
|
+
return getattr(_platform(), name)
|
|
49
|
+
|
|
50
|
+
def __repr__(self):
|
|
51
|
+
return "<analytics_platform.start platform%s>" % ("" if _made is not None else " (not made yet)")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
platform = _Platform()
|
|
55
|
+
|
|
56
|
+
_MARK = "_analytics_platform_started"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _hook_flask():
|
|
60
|
+
try:
|
|
61
|
+
from flask import Flask
|
|
62
|
+
except Exception:
|
|
63
|
+
return
|
|
64
|
+
if getattr(Flask.__call__, _MARK, False):
|
|
65
|
+
return
|
|
66
|
+
original = Flask.__call__
|
|
67
|
+
hook_lock = threading.Lock()
|
|
68
|
+
|
|
69
|
+
# The first request's own thread hooks the app in before Flask marks it
|
|
70
|
+
# as started (after that it refuses new before_request functions); other
|
|
71
|
+
# threads wait for it, so none gets past without it.
|
|
72
|
+
def __call__(self, environ, start_response):
|
|
73
|
+
if not self.__dict__.get(_MARK):
|
|
74
|
+
with hook_lock:
|
|
75
|
+
if not self.__dict__.get(_MARK):
|
|
76
|
+
try:
|
|
77
|
+
_platform().init_flask(self)
|
|
78
|
+
except Exception as error:
|
|
79
|
+
print("[analytics-platform] couldn't add itself to this Flask app; call platform.init_flask(app) by hand: %s" % error)
|
|
80
|
+
self.__dict__[_MARK] = True
|
|
81
|
+
return original(self, environ, start_response)
|
|
82
|
+
|
|
83
|
+
setattr(__call__, _MARK, True)
|
|
84
|
+
Flask.__call__ = __call__
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _hook_starlette():
|
|
88
|
+
try:
|
|
89
|
+
from starlette.applications import Starlette
|
|
90
|
+
from starlette.middleware import Middleware
|
|
91
|
+
except Exception:
|
|
92
|
+
return
|
|
93
|
+
if getattr(Starlette.__call__, _MARK, False):
|
|
94
|
+
return
|
|
95
|
+
original = Starlette.__call__
|
|
96
|
+
|
|
97
|
+
# Before the app builds its middleware stack, on its first call. Only the
|
|
98
|
+
# outermost app: a mounted one is called with scope["app"] already set by
|
|
99
|
+
# the app it's mounted in. Outermost of your own middleware, as
|
|
100
|
+
# add_middleware() called last puts it.
|
|
101
|
+
async def __call__(self, scope, receive, send):
|
|
102
|
+
if not self.__dict__.get(_MARK):
|
|
103
|
+
self.__dict__[_MARK] = True
|
|
104
|
+
outermost = "app" not in scope
|
|
105
|
+
by_hand = any(getattr(m, "cls", None) is PlatformMiddleware for m in getattr(self, "user_middleware", []))
|
|
106
|
+
if outermost and not by_hand and getattr(self, "middleware_stack", None) is None:
|
|
107
|
+
self.user_middleware.insert(0, Middleware(PlatformMiddleware, platform=platform))
|
|
108
|
+
await original(self, scope, receive, send)
|
|
109
|
+
|
|
110
|
+
setattr(__call__, _MARK, True)
|
|
111
|
+
Starlette.__call__ = __call__
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# Each only when its framework is installed.
|
|
115
|
+
_hook_flask()
|
|
116
|
+
_hook_starlette()
|
|
117
|
+
|
|
118
|
+
__all__ = ["platform"]
|
analytics_platform-0.3.7/README.md → analytics_platform-0.3.9/analytics_platform.egg-info/PKG-INFO
RENAMED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: analytics-platform
|
|
3
|
+
Version: 0.3.9
|
|
4
|
+
Summary: Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard.
|
|
5
|
+
Author: steviec87
|
|
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
|
|
11
|
+
Keywords: firewall,waf,rate-limit,error-monitoring,analytics,server-monitoring,django,flask,fastapi,celery,rq,dramatiq,wsgi,asgi
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Framework :: Django
|
|
20
|
+
Classifier: Framework :: Flask
|
|
21
|
+
Classifier: Framework :: FastAPI
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Classifier: Topic :: Internet :: Log Analysis
|
|
24
|
+
Classifier: Topic :: Security
|
|
25
|
+
Classifier: Topic :: System :: Monitoring
|
|
26
|
+
Requires-Python: >=3.8
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
1
31
|
# analytics-platform (Python)
|
|
2
32
|
|
|
3
33
|
One package for your Python server, run from your analytics dashboard: a
|
|
@@ -9,7 +39,42 @@ dependencies; Python 3.8 or later.
|
|
|
9
39
|
pip install analytics-platform
|
|
10
40
|
```
|
|
11
41
|
|
|
12
|
-
|
|
42
|
+
Put your keys in `.env` (site settings has all three, ready to copy), or in
|
|
43
|
+
your host's environment:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
ANALYTICS_SITE_ID=YOUR_SITE_ID
|
|
47
|
+
ANALYTICS_SITE_SECRET=YOUR_SITE_SECRET
|
|
48
|
+
ANALYTICS_ENDPOINT=https://YOUR_COLLECTOR_URL
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The package reads only the `ANALYTICS_*` lines of the `.env` in the folder the
|
|
52
|
+
server starts in (Django: beside `manage.py`), and never writes them into
|
|
53
|
+
`os.environ`, so your own `.env` loading is unaffected. Behind Cloudflare, add
|
|
54
|
+
`ANALYTICS_TRUST_PROXY=cloudflare`; behind Nginx or a load balancer,
|
|
55
|
+
`ANALYTICS_TRUST_PROXY=1`. Several services on one site? Give each
|
|
56
|
+
`ANALYTICS_SERVER_NAME=api` (or `worker`, and so on).
|
|
57
|
+
|
|
58
|
+
## Flask and FastAPI: one line
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from analytics_platform.start import platform
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Put it near the top of the module that makes your app. On the app's first
|
|
65
|
+
request, the package puts itself in front of it: Flask through
|
|
66
|
+
`init_flask(app)`, and FastAPI or any Starlette app through its middleware, as
|
|
67
|
+
the outermost of yours. It works whether the import is above or below
|
|
68
|
+
`app = Flask(...)`. An app that already has the platform by hand gets nothing
|
|
69
|
+
added a second time, and a FastAPI app mounted inside another isn't counted
|
|
70
|
+
twice.
|
|
71
|
+
|
|
72
|
+
Use the same import wherever you call `platform.track()` and the rest: it's
|
|
73
|
+
one platform for the whole process. It's made on first use, not on import, so
|
|
74
|
+
under Gunicorn or uWSGI with `--preload` its background threads start in each
|
|
75
|
+
worker, not in the master.
|
|
76
|
+
|
|
77
|
+
## Django: one line in settings
|
|
13
78
|
|
|
14
79
|
```python
|
|
15
80
|
# settings.py
|
|
@@ -17,36 +82,31 @@ MIDDLEWARE = [
|
|
|
17
82
|
"analytics_platform.django.PlatformMiddleware", # first
|
|
18
83
|
# ...
|
|
19
84
|
]
|
|
85
|
+
```
|
|
20
86
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
# "trust_proxy": "cloudflare", # every request through Cloudflare
|
|
27
|
-
}
|
|
87
|
+
That's all with the keys in `.env` or the environment. Options in code go in
|
|
88
|
+
`ANALYTICS_PLATFORM`, and win over the environment:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
ANALYTICS_PLATFORM = {"trust_proxy": 1, "errors": {"capture_logging": True}}
|
|
28
92
|
```
|
|
29
93
|
|
|
30
94
|
In your code: `from analytics_platform.django import get_platform`. The
|
|
31
95
|
package tells the collector it is a Django site (`analytics-platform-django/…`),
|
|
32
96
|
so attack patterns written for Django reach it.
|
|
33
97
|
|
|
34
|
-
##
|
|
98
|
+
## Setting it up in code instead
|
|
99
|
+
|
|
100
|
+
For options in code with Flask, FastAPI or another app (use this or the line
|
|
101
|
+
above, not both). Any key left out is read from `ANALYTICS_*`:
|
|
35
102
|
|
|
36
103
|
```python
|
|
37
104
|
from analytics_platform import create_platform, PlatformMiddleware
|
|
38
105
|
|
|
39
|
-
platform = create_platform(
|
|
40
|
-
endpoint="https://YOUR_COLLECTOR_URL")
|
|
41
|
-
app.add_middleware(PlatformMiddleware, platform=platform)
|
|
42
|
-
```
|
|
106
|
+
platform = create_platform(errors={"capture_logging": True})
|
|
43
107
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
```python
|
|
47
|
-
platform = create_platform(site_id="YOUR_SITE_ID", secret=os.environ["ANALYTICS_SITE_SECRET"],
|
|
48
|
-
endpoint="https://YOUR_COLLECTOR_URL")
|
|
49
|
-
platform.init_flask(app)
|
|
108
|
+
app.add_middleware(PlatformMiddleware, platform=platform) # FastAPI / Starlette
|
|
109
|
+
platform.init_flask(app) # Flask
|
|
50
110
|
```
|
|
51
111
|
|
|
52
112
|
Other WSGI apps: `app = platform.wsgi(app)`; other ASGI apps: `app = platform.asgi(app)`.
|
{analytics_platform-0.3.7 → analytics_platform-0.3.9}/analytics_platform.egg-info/SOURCES.txt
RENAMED
|
@@ -2,6 +2,7 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
analytics_platform/__init__.py
|
|
5
|
+
analytics_platform/_env.py
|
|
5
6
|
analytics_platform/analytics.py
|
|
6
7
|
analytics_platform/audit.py
|
|
7
8
|
analytics_platform/calls.py
|
|
@@ -25,6 +26,7 @@ analytics_platform/queues.py
|
|
|
25
26
|
analytics_platform/request_rules.py
|
|
26
27
|
analytics_platform/resources.py
|
|
27
28
|
analytics_platform/rq.py
|
|
29
|
+
analytics_platform/start.py
|
|
28
30
|
analytics_platform.egg-info/PKG-INFO
|
|
29
31
|
analytics_platform.egg-info/SOURCES.txt
|
|
30
32
|
analytics_platform.egg-info/dependency_links.txt
|
|
@@ -37,12 +39,15 @@ tests/test_crawlers.py
|
|
|
37
39
|
tests/test_errors.py
|
|
38
40
|
tests/test_firewall.py
|
|
39
41
|
tests/test_hardening.py
|
|
42
|
+
tests/test_headers.py
|
|
40
43
|
tests/test_limits.py
|
|
41
44
|
tests/test_page_limits.py
|
|
42
45
|
tests/test_proxy.py
|
|
46
|
+
tests/test_proxy_answer.py
|
|
43
47
|
tests/test_queries.py
|
|
44
48
|
tests/test_redis.py
|
|
45
49
|
tests/test_request_rules.py
|
|
46
50
|
tests/test_resources.py
|
|
47
51
|
tests/test_rq_dramatiq.py
|
|
52
|
+
tests/test_start.py
|
|
48
53
|
tests/test_static_late.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.
|
|
13
|
+
version = "0.3.9"
|
|
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()
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""The one-line setup: keys from ANALYTICS_* or a .env file (never written to
|
|
2
|
+
os.environ), and analytics_platform.start putting itself in front of Flask
|
|
3
|
+
and FastAPI/Starlette apps on their first request, and Django's settings
|
|
4
|
+
block becoming optional. The framework tests skip where the framework isn't
|
|
5
|
+
installed; run them where Flask, FastAPI (with httpx) and Django are."""
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
13
|
+
|
|
14
|
+
from analytics_platform import Platform, PlatformMiddleware, _env # noqa: E402
|
|
15
|
+
|
|
16
|
+
SITE = "a" * 24
|
|
17
|
+
KEYS = {"ANALYTICS_SITE_ID": SITE, "ANALYTICS_SITE_SECRET": "s3cret", "ANALYTICS_ENDPOINT": "http://127.0.0.1:9"}
|
|
18
|
+
OFF = {name: False for name in ("firewall", "errors", "analytics", "performance", "crawlers", "forms", "headers", "audit",
|
|
19
|
+
"flags", "maintenance", "inventory", "posture", "resources", "proxy")}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def installed(name):
|
|
23
|
+
try:
|
|
24
|
+
__import__(name)
|
|
25
|
+
return True
|
|
26
|
+
except Exception:
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EnvTest(unittest.TestCase):
|
|
31
|
+
def test_options_from_the_environment(self):
|
|
32
|
+
found = _env.options_from({**KEYS, "ANALYTICS_TRUST_PROXY": "2", "ANALYTICS_SERVER_NAME": "api"})
|
|
33
|
+
self.assertEqual(found, {"site_id": SITE, "secret": "s3cret", "endpoint": "http://127.0.0.1:9",
|
|
34
|
+
"server_name": "api", "trust_proxy": 2})
|
|
35
|
+
self.assertEqual(_env.options_from({"ANALYTICS_TRUST_PROXY": "Cloudflare"})["trust_proxy"], "cloudflare")
|
|
36
|
+
self.assertEqual(_env.options_from({"ANALYTICS_FIREWALL_ENDPOINT": "https://x"})["endpoint"], "https://x")
|
|
37
|
+
self.assertEqual(_env.options_from({"ANALYTICS_TRUST_PROXY": "nonsense", "ANALYTICS_SITE_ID": " "}), {})
|
|
38
|
+
|
|
39
|
+
def test_dotenv_lines_are_read_and_never_loaded(self):
|
|
40
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
41
|
+
path = os.path.join(folder, ".env")
|
|
42
|
+
with open(path, "w") as handle:
|
|
43
|
+
handle.write('DATABASE_URL=x\nANALYTICS_SITE_ID="%s"\nexport ANALYTICS_SITE_SECRET=from-file # note\n' % SITE)
|
|
44
|
+
with mock.patch.dict(os.environ, {"ANALYTICS_ENDPOINT": "http://127.0.0.1:9"}, clear=True):
|
|
45
|
+
filled = _env.from_environment({"secret": None}, path)
|
|
46
|
+
self.assertNotIn("ANALYTICS_SITE_SECRET", os.environ)
|
|
47
|
+
self.assertNotIn("DATABASE_URL", os.environ)
|
|
48
|
+
self.assertEqual((filled["site_id"], filled["secret"], filled["endpoint"]), (SITE, "from-file", "http://127.0.0.1:9"))
|
|
49
|
+
|
|
50
|
+
def test_the_environment_wins_over_the_file_and_given_options_over_both(self):
|
|
51
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
52
|
+
path = os.path.join(folder, ".env")
|
|
53
|
+
with open(path, "w") as handle:
|
|
54
|
+
handle.write("ANALYTICS_SITE_SECRET=file\nANALYTICS_SERVER_NAME=file\n")
|
|
55
|
+
with mock.patch.dict(os.environ, {"ANALYTICS_SITE_SECRET": "env"}, clear=True):
|
|
56
|
+
filled = _env.from_environment({"server_name": "given"}, path)
|
|
57
|
+
self.assertEqual((filled["secret"], filled["server_name"]), ("env", "given"))
|
|
58
|
+
|
|
59
|
+
def test_platform_with_no_keys_reads_them_from_the_environment(self):
|
|
60
|
+
with mock.patch.dict(os.environ, KEYS, clear=True):
|
|
61
|
+
platform = Platform(**OFF)
|
|
62
|
+
self.assertTrue(platform.core.valid)
|
|
63
|
+
with mock.patch.dict(os.environ, {}, clear=True), mock.patch("builtins.print") as printed:
|
|
64
|
+
self.assertFalse(Platform(**OFF).core.valid)
|
|
65
|
+
self.assertIn("ANALYTICS_SITE_SECRET", printed.call_args[0][0])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def started():
|
|
69
|
+
"""analytics_platform.start, with a fresh platform made from KEYS."""
|
|
70
|
+
from analytics_platform import start
|
|
71
|
+
start._made = None
|
|
72
|
+
return start
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@unittest.skipUnless(installed("flask"), "Flask isn't installed")
|
|
76
|
+
class FlaskTest(unittest.TestCase):
|
|
77
|
+
def test_hooks_itself_in_on_the_first_request_wherever_the_import_is(self):
|
|
78
|
+
from flask import Flask
|
|
79
|
+
app = Flask("before") # made before the import
|
|
80
|
+
app.get("/")(lambda: "ok")
|
|
81
|
+
with mock.patch.dict(os.environ, KEYS):
|
|
82
|
+
start = started()
|
|
83
|
+
response = app.test_client().get("/")
|
|
84
|
+
self.assertEqual(response.status_code, 200)
|
|
85
|
+
self.assertIs(app.extensions["analytics_platform"], start._made)
|
|
86
|
+
wrapped = app.wsgi_app
|
|
87
|
+
app.test_client().get("/")
|
|
88
|
+
self.assertIs(app.wsgi_app, wrapped, "wrapped once, not on every request")
|
|
89
|
+
|
|
90
|
+
def test_an_app_set_up_by_hand_is_left_alone(self):
|
|
91
|
+
from flask import Flask
|
|
92
|
+
with mock.patch.dict(os.environ, KEYS):
|
|
93
|
+
started()
|
|
94
|
+
own = Platform(**OFF)
|
|
95
|
+
app = Flask("by_hand")
|
|
96
|
+
app.get("/")(lambda: "ok")
|
|
97
|
+
own.init_flask(app)
|
|
98
|
+
wrapped = app.wsgi_app
|
|
99
|
+
self.assertEqual(app.test_client().get("/").status_code, 200)
|
|
100
|
+
self.assertIs(app.extensions["analytics_platform"], own)
|
|
101
|
+
self.assertIs(app.wsgi_app, wrapped)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@unittest.skipUnless(installed("fastapi") and installed("httpx"), "FastAPI or httpx isn't installed")
|
|
105
|
+
class FastAPITest(unittest.TestCase):
|
|
106
|
+
def test_outermost_of_your_middleware_and_not_in_a_mounted_app(self):
|
|
107
|
+
from fastapi import FastAPI
|
|
108
|
+
from fastapi.testclient import TestClient
|
|
109
|
+
from starlette.middleware.gzip import GZipMiddleware
|
|
110
|
+
with mock.patch.dict(os.environ, KEYS):
|
|
111
|
+
started()
|
|
112
|
+
app = FastAPI()
|
|
113
|
+
app.add_middleware(GZipMiddleware)
|
|
114
|
+
sub = FastAPI()
|
|
115
|
+
sub.get("/x")(lambda: {"ok": True})
|
|
116
|
+
app.mount("/sub", sub)
|
|
117
|
+
app.get("/")(lambda: {"ok": True})
|
|
118
|
+
client = TestClient(app)
|
|
119
|
+
self.assertEqual(client.get("/").status_code, 200)
|
|
120
|
+
self.assertEqual(client.get("/sub/x").status_code, 200)
|
|
121
|
+
self.assertEqual([m.cls for m in app.user_middleware], [PlatformMiddleware, GZipMiddleware])
|
|
122
|
+
self.assertEqual([m.cls for m in sub.user_middleware], [], "a mounted app isn't counted twice")
|
|
123
|
+
|
|
124
|
+
def test_an_app_with_it_by_hand_gets_no_second_one(self):
|
|
125
|
+
from fastapi import FastAPI
|
|
126
|
+
from fastapi.testclient import TestClient
|
|
127
|
+
with mock.patch.dict(os.environ, KEYS):
|
|
128
|
+
started()
|
|
129
|
+
app = FastAPI()
|
|
130
|
+
app.add_middleware(PlatformMiddleware, platform=Platform(**OFF))
|
|
131
|
+
app.get("/")(lambda: {"ok": True})
|
|
132
|
+
self.assertEqual(TestClient(app).get("/").status_code, 200)
|
|
133
|
+
self.assertEqual([m.cls for m in app.user_middleware], [PlatformMiddleware])
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@unittest.skipUnless(installed("django"), "Django isn't installed")
|
|
137
|
+
class DjangoTest(unittest.TestCase):
|
|
138
|
+
def test_no_settings_block_the_keys_from_the_projects_dotenv(self):
|
|
139
|
+
from django.conf import settings
|
|
140
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
141
|
+
with open(os.path.join(folder, ".env"), "w") as handle:
|
|
142
|
+
handle.write("\n".join("%s=%s" % item for item in KEYS.items()))
|
|
143
|
+
if not settings.configured:
|
|
144
|
+
settings.configure(BASE_DIR=folder)
|
|
145
|
+
else:
|
|
146
|
+
settings.BASE_DIR = folder
|
|
147
|
+
from analytics_platform import django as integration
|
|
148
|
+
integration._platform = None
|
|
149
|
+
with mock.patch.dict(os.environ, {}, clear=True):
|
|
150
|
+
platform = integration.get_platform()
|
|
151
|
+
self.assertNotIn("ANALYTICS_SITE_SECRET", os.environ)
|
|
152
|
+
self.assertTrue(platform.core.valid)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
unittest.main()
|
|
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
|
|
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.3.7 → analytics_platform-0.3.9}/analytics_platform.egg-info/top_level.txt
RENAMED
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|