analytics-platform 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,264 @@
1
+ """How the server is set up (protocol in sdk/protocols/posture.md): the
2
+ settings that leave the app open (Django's, when it's a Django site), whether
3
+ it runs as root, code anyone on the machine can write, and files in the
4
+ folders it serves to the web that should never be public. Sent to
5
+ POST /events/posture a few minutes after start and then once a day (the
6
+ collector's `every`), so Security -> Security check can say what to change.
7
+
8
+ It only reads: nothing here changes a setting, a permission or a file, and no
9
+ file's contents are sent -- paths, sizes, times and short values only. The
10
+ walk is bounded and runs in a background thread, never in a request; with no
11
+ folder served to the web there is nothing to walk and only the settings are
12
+ reported.
13
+ """
14
+ import os
15
+ import re
16
+ import socket
17
+ import stat
18
+ from datetime import datetime, timezone
19
+
20
+ from .core import Periodic
21
+
22
+ FIRST = 180
23
+ RETRY_EVERY = 60 * 60
24
+ EVERY = 24 * 60 * 60
25
+ MAX_CHECKS = 200
26
+ MAX_EXPOSURE = 50
27
+ MAX_ENTRIES = 20000
28
+ MAX_DEPTH = 6
29
+ MAX_PERM_CHECKS = 3000
30
+ SKIP_DIRS = {".git", ".svn", ".hg", "node_modules", "vendor", "cache", ".cache", ".history", "__pycache__"}
31
+ CODE_FILE = re.compile(r"\.(py|pyw|js|cjs|mjs|php[0-9]?|phtml|rb|pl|sh)$", re.I)
32
+ # Files that shouldn't be downloadable, by name; first match wins.
33
+ EXPOSURE = [
34
+ ("database_dump", re.compile(r"\.sql(\.(gz|bz2|xz|zip))?$", re.I)),
35
+ ("private_key", re.compile(r"^(id_rsa|id_dsa|id_ecdsa|id_ed25519)$|\.(pem|key|p12|pfx|jks)$", re.I)),
36
+ ("env", re.compile(r"^\.env($|\.)|^env\.(ini|txt)$", re.I)),
37
+ ("archive", re.compile(r"\.(zip|tar|tgz|tar\.gz|tar\.bz2|rar|7z)$", re.I)),
38
+ ("backup", re.compile(r"\.(bak|old|orig|save|swp|copy|backup)$|~$|\.(py|json|conf|config|ini|ya?ml)\.(bak|old|orig|save|txt)$", re.I)),
39
+ ("info", re.compile(r"^(phpinfo|info|adminer)\.php$", re.I)),
40
+ ("log", re.compile(r"^(debug|error|access)[-_.]?log$|\.log$", re.I)),
41
+ ]
42
+ # Django's own placeholders, and what a key looks like when nobody set one.
43
+ INSECURE_KEYS = ("django-insecure-", "changeme", "secret", "your-secret-key", "put-your-secret-key-here")
44
+
45
+
46
+ def kind_of(name):
47
+ for kind, pattern in EXPOSURE:
48
+ if pattern.search(name):
49
+ return kind
50
+ return None
51
+
52
+
53
+ def without_nested(roots):
54
+ """A folder inside another folder on the list is already covered: without
55
+ this, every file under it would be reported twice."""
56
+ unique = {}
57
+ for root in roots:
58
+ unique.setdefault(root["dir"], root)
59
+ return [root for root in unique.values()
60
+ if not any(other != root["dir"] and (root["dir"] + "/").startswith(other + "/") for other in unique)]
61
+
62
+
63
+ def roots_of(given):
64
+ """[{"dir", "prefix"}]: `prefix` is where the web serves the folder, so the
65
+ path we report is the one the dashboard can ask the site for. Not told:
66
+ Django's STATIC_ROOT and MEDIA_ROOT, when they're set and there."""
67
+ roots = []
68
+ for entry in given or []:
69
+ folder = entry.get("dir") if isinstance(entry, dict) else entry
70
+ if not folder:
71
+ continue
72
+ prefix = str(entry.get("prefix") or "").strip("/") if isinstance(entry, dict) else ""
73
+ roots.append({"dir": os.path.abspath(str(folder)), "prefix": prefix})
74
+ if roots:
75
+ return without_nested(roots)
76
+ for folder, url in django_static_dirs():
77
+ if folder and os.path.isdir(folder):
78
+ roots.append({"dir": os.path.abspath(folder), "prefix": str(url or "").strip("/")})
79
+ return without_nested(roots)
80
+
81
+
82
+ def django_settings():
83
+ """Django's settings, or None when this isn't a configured Django site."""
84
+ try:
85
+ from django.conf import settings
86
+ if not settings.configured:
87
+ return None
88
+ settings.DEBUG # raises when there are no settings to read
89
+ return settings
90
+ except Exception:
91
+ return None
92
+
93
+
94
+ def django_static_dirs():
95
+ settings = django_settings()
96
+ if settings is None:
97
+ return []
98
+ out = []
99
+ for folder, url in ((getattr(settings, "STATIC_ROOT", None), getattr(settings, "STATIC_URL", "")),
100
+ (getattr(settings, "MEDIA_ROOT", None), getattr(settings, "MEDIA_URL", ""))):
101
+ if folder:
102
+ out.append((str(folder), str(url or "")))
103
+ return out
104
+
105
+
106
+ def django_checks():
107
+ """The Django settings that decide how much a hole in the site is worth."""
108
+ settings = django_settings()
109
+ if settings is None:
110
+ return []
111
+ checks = []
112
+
113
+ def add(check_id, failed, value=""):
114
+ row = {"id": check_id, "state": "unknown" if failed is None else ("fail" if failed else "pass")}
115
+ if value:
116
+ row["value"] = value
117
+ checks.append(row)
118
+
119
+ add("app.django_debug", bool(getattr(settings, "DEBUG", False)))
120
+ key = str(getattr(settings, "SECRET_KEY", "") or "")
121
+ add("app.secret_key", not key or len(key) < 32 or any(mark in key.lower() for mark in INSECURE_KEYS))
122
+ hosts = list(getattr(settings, "ALLOWED_HOSTS", []) or [])
123
+ add("app.allowed_hosts", "*" in hosts, ", ".join(str(h) for h in hosts[:10]))
124
+ # Cookie flags only matter once the site is on HTTPS, which these say it is.
125
+ https = bool(getattr(settings, "SECURE_SSL_REDIRECT", False)) or bool(getattr(settings, "SESSION_COOKIE_SECURE", False)) \
126
+ or bool(getattr(settings, "CSRF_COOKIE_SECURE", False)) or bool(getattr(settings, "SECURE_HSTS_SECONDS", 0))
127
+ add("app.session_cookie_httponly", not getattr(settings, "SESSION_COOKIE_HTTPONLY", True))
128
+ if https:
129
+ add("app.session_cookie_secure", not getattr(settings, "SESSION_COOKIE_SECURE", False))
130
+ add("app.csrf_cookie_secure", not getattr(settings, "CSRF_COOKIE_SECURE", False))
131
+ add("app.https_redirect", not getattr(settings, "SECURE_SSL_REDIRECT", False))
132
+ return checks
133
+
134
+
135
+ def app_checks():
136
+ checks = []
137
+ for check in django_checks():
138
+ checks.append(check)
139
+ if hasattr(os, "getuid"):
140
+ # Running the web app as root turns any hole in it into the whole machine.
141
+ checks.append({"id": "server.runs_as_root", "state": "fail" if os.getuid() == 0 else "pass"})
142
+ return checks
143
+
144
+
145
+ def walk(roots):
146
+ """One bounded walk per root: (exposure, writable or None, examples, truncated)."""
147
+ exposure, examples = [], []
148
+ writable = checked = entries = 0
149
+ for root in roots:
150
+ queue = [(root["dir"], 0)]
151
+ while queue:
152
+ folder, depth = queue.pop(0)
153
+ try:
154
+ names = os.listdir(folder)
155
+ except OSError:
156
+ continue
157
+ for name in names:
158
+ entries += 1
159
+ if entries > MAX_ENTRIES:
160
+ return exposure, (writable if checked else None), examples, True
161
+ path = os.path.join(folder, name)
162
+ if os.path.islink(path):
163
+ continue
164
+ relative = "/".join(p for p in (root["prefix"], os.path.relpath(path, root["dir"]).replace(os.sep, "/")) if p)
165
+ if os.path.isdir(path):
166
+ if name in (".git", ".svn", ".hg", ".bzr"):
167
+ if len(exposure) < MAX_EXPOSURE:
168
+ exposure.append({"path": relative + "/config", "kind": "vcs"})
169
+ continue
170
+ if depth + 1 <= MAX_DEPTH and name not in SKIP_DIRS:
171
+ queue.append((path, depth + 1))
172
+ continue
173
+ if not os.path.isfile(path):
174
+ continue
175
+ kind = kind_of(name)
176
+ if kind and len(exposure) < MAX_EXPOSURE:
177
+ row = {"path": relative, "kind": kind}
178
+ try:
179
+ info = os.stat(path)
180
+ row["size"] = info.st_size
181
+ row["mtime"] = datetime.fromtimestamp(info.st_mtime, timezone.utc).isoformat().replace("+00:00", "Z")
182
+ except OSError:
183
+ pass
184
+ exposure.append(row)
185
+ if checked < MAX_PERM_CHECKS and CODE_FILE.search(name):
186
+ checked += 1
187
+ try:
188
+ if os.stat(path).st_mode & stat.S_IWOTH:
189
+ writable += 1
190
+ if len(examples) < 3:
191
+ examples.append(relative)
192
+ except OSError:
193
+ pass
194
+ return exposure, (writable if checked else None), examples, False
195
+
196
+
197
+ class Posture:
198
+ """Options: roots (the folders the web serves, as "public" or
199
+ {"dir": ..., "prefix": "static"}; default: Django's STATIC_ROOT and
200
+ MEDIA_ROOT), host (the server's name), checks (a callable returning more
201
+ checks of your own)."""
202
+
203
+ def __init__(self, core, roots=None, host=None, checks=None, **_):
204
+ self.core = core
205
+ self.roots = roots_of(roots)
206
+ self.extra = checks
207
+ self.host = re.sub(r"[^A-Za-z0-9._-]", "-", str(host if host is not None else socket.gethostname()))[:100] or "server"
208
+ self.job = Periodic(self._report, FIRST) if core.valid else None
209
+
210
+ def report(self):
211
+ """What would be reported now (also used by the job and by tests)."""
212
+ exposure, writable, examples, truncated = walk(self.roots)
213
+ checks = app_checks()
214
+ if writable is not None:
215
+ row = {"id": "server.code_writable", "state": "fail" if writable else "pass"}
216
+ if writable:
217
+ row["value"] = "%d files, e.g. %s" % (writable, ", ".join(examples))
218
+ checks.append(row)
219
+ try:
220
+ for check in (self.extra() if callable(self.extra) else []) or []:
221
+ if isinstance(check, dict) and check.get("id") and check.get("state") in ("pass", "fail", "unknown"):
222
+ row = {"id": str(check["id"]).lower()[:60], "state": check["state"]}
223
+ if check.get("value"):
224
+ row["value"] = str(check["value"])[:200]
225
+ checks.append(row)
226
+ except Exception:
227
+ pass
228
+ import platform as _platform
229
+ body = {
230
+ "site_id": self.core.site_id,
231
+ "host": self.host,
232
+ "at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
233
+ "checks": checks[:MAX_CHECKS],
234
+ "facts": {"agent": "python/%s" % _platform.python_version()},
235
+ }
236
+ if self.roots:
237
+ # Left out when there's no folder to walk: the dashboard then says
238
+ # this wasn't checked, instead of taking an empty walk for a clean one.
239
+ body["exposure"] = exposure[:MAX_EXPOSURE]
240
+ body["facts"]["webroot"] = self.roots[0]["dir"]
241
+ if truncated:
242
+ body["truncated"] = True
243
+ return body
244
+
245
+ def _report(self):
246
+ # The set-up checks ride the software inventory switch.
247
+ if not self.core.is_on("inventory"):
248
+ return RETRY_EVERY
249
+ res = self.core.send("posture", "/events/posture", auth="bearer", body=self.report())
250
+ if res is None or not getattr(res, "ok", False):
251
+ return RETRY_EVERY
252
+ try:
253
+ answer = res.json()
254
+ except Exception:
255
+ answer = {}
256
+ every = int(answer.get("every") or EVERY) if isinstance(answer, dict) else EVERY
257
+ return max(RETRY_EVERY, every)
258
+
259
+ def flush(self):
260
+ pass
261
+
262
+ def close(self):
263
+ if self.job:
264
+ self.job.stop()
@@ -0,0 +1,170 @@
1
+ """First-party proxy: serving the tracker from the site's own domain, so ad
2
+ blockers don't hide visitors.
3
+
4
+ Blockers stop analytics by recognising the address a script comes from and
5
+ what it is called ("track.js", "collect"). The answer is to serve both from
6
+ the site's own domain, which until now meant writing Nginx, Cloudflare or
7
+ Vercel rules by hand. This app is already a server on that domain, so the
8
+ package does it itself:
9
+
10
+ <prefix>/js/<rest> -> <endpoint>/<rest> (tracker files)
11
+ <prefix>/api/<rest> -> <endpoint>/collectanalytics/<rest> (the collector)
12
+
13
+ The same two rules as the hand-written ones, so one install check covers both.
14
+ The prefix comes from the site secret: not guessable from the page, and the
15
+ same every time, so a browser's cache and a CDN in front stay valid.
16
+
17
+ It answers before every other module: a visitor's beacon is our own traffic,
18
+ not a page of the site, so it shouldn't be counted, timed or rate-limited.
19
+ """
20
+ import hashlib
21
+ import re
22
+ import time
23
+ import urllib.error
24
+ import urllib.request
25
+
26
+ # The tracker files, by name: anything else is refused rather than forwarded.
27
+ FILES = frozenset((
28
+ "track.js", "s.js", "snapshot.js", "replay.js", "survey.js", "banner.js",
29
+ "leadform.js", "notice.js", "editor.js", "picker.js", "feedback.js", "shield.js",
30
+ ))
31
+ # A pinned version never changes; /v<major>/ follows new releases, so it is
32
+ # held for an hour, the same as the collector serves it with.
33
+ TTL_PINNED_S = 7 * 24 * 60 * 60
34
+ TTL_MAJOR_S = 60 * 60
35
+ TIMEOUT = 5
36
+ MAX_BODY = 6 * 1024 * 1024
37
+ # The collector reads these; everything else, cookies above all, stays here.
38
+ PASS_HEADERS = ("content-type", "user-agent", "accept-language", "referer", "origin",
39
+ "sec-fetch-site", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform")
40
+ SAFE_PATH = re.compile(r"^[A-Za-z0-9/_.-]{1,120}$")
41
+ FILE_PATH = re.compile(r"^v(\d+(?:\.\d+\.\d+)?)/([a-z]+\.js)$")
42
+ NOT_FOUND = (404, [("Content-Type", "text/plain; charset=utf-8")], b"Not found")
43
+
44
+
45
+ def prefix_for(secret):
46
+ return hashlib.sha256(("proxy|" + str(secret)).encode("utf-8")).hexdigest()[:8]
47
+
48
+
49
+ class Proxy:
50
+ """prefix: the path it answers on (default: from the secret)."""
51
+
52
+ def __init__(self, core, prefix=None, enabled=True):
53
+ self.core = core
54
+ self.enabled = enabled
55
+ self.prefix = re.sub(r"[^A-Za-z0-9_-]", "", str(prefix or prefix_for(core.secret)))
56
+ # One copy of each tracker file, so a repeat visitor costs no upstream
57
+ # request. A handful of small files: it never grows.
58
+ self._files = {}
59
+
60
+ def on(self):
61
+ return bool(self.enabled and self.core.valid and self.prefix and self.core.is_on("proxy"))
62
+
63
+ def script_url(self, version="1"):
64
+ """The tracker's address on this site, for the page's script tag."""
65
+ return "/%s/js/v%s/s.js" % (self.prefix, version)
66
+
67
+ def collect_url(self):
68
+ """Where visits are sent, for the tag's data-collect."""
69
+ return "/%s/api/e" % self.prefix
70
+
71
+ def match(self, path):
72
+ """("js" | "api", the rest) when the address is ours, else None."""
73
+ if not self.on():
74
+ return None
75
+ head = "/%s/" % self.prefix
76
+ if not path.startswith(head):
77
+ return None
78
+ rest = path[len(head):]
79
+ for kind in ("js", "api"):
80
+ if rest.startswith(kind + "/"):
81
+ tail = rest[len(kind) + 1:]
82
+ if tail and ".." not in tail and SAFE_PATH.match(tail):
83
+ return kind, tail
84
+ break
85
+ return "bad", ""
86
+
87
+ def handle(self, kind, rest, method="GET", query="", headers=None, body=None, client_ip=""):
88
+ """(status, [(name, value)], body). Blocking: an ASGI caller runs it
89
+ in a thread so the event loop keeps turning."""
90
+ if kind == "js":
91
+ return self._file(rest)
92
+ if kind != "api":
93
+ return NOT_FOUND
94
+ return self._collector(rest, method, query, headers or {}, body, client_ip)
95
+
96
+ def _file(self, rest):
97
+ found = FILE_PATH.match(rest)
98
+ if not found or found.group(2) not in FILES:
99
+ return NOT_FOUND
100
+ pinned = "." in found.group(1)
101
+ held = self._files.get(rest)
102
+ body = None
103
+ if held and held[1] > time.time() - (TTL_PINNED_S if pinned else TTL_MAJOR_S):
104
+ body = held[0]
105
+ if body is None:
106
+ try:
107
+ request = urllib.request.Request(self.core.endpoint + "/" + rest, method="GET")
108
+ with urllib.request.urlopen(request, timeout=TIMEOUT) as answer:
109
+ fetched = answer.read(MAX_BODY)
110
+ if len(fetched) > 500:
111
+ body = fetched
112
+ self._files[rest] = (body, time.time())
113
+ except Exception as error:
114
+ self.core.log("proxy", "could not fetch %s: %s" % (rest, error))
115
+ # Upstream is unreachable: an old copy beats no tracker at all.
116
+ if body is None and held:
117
+ body = held[0]
118
+ if body is None:
119
+ return 502, [], b""
120
+ return 200, [
121
+ ("Content-Type", "application/javascript; charset=utf-8"),
122
+ ("Cache-Control", "public, max-age=31536000, immutable" if pinned else "public, max-age=3600"),
123
+ ("X-Content-Type-Options", "nosniff"),
124
+ ], body
125
+
126
+ def _collector(self, rest, method, query, headers, body, client_ip):
127
+ method = (method or "GET").upper()
128
+ if method == "OPTIONS":
129
+ # Same origin as the page, so a preflight shouldn't happen; answer
130
+ # it cheaply rather than passing it on.
131
+ return 204, [("Access-Control-Allow-Methods", "GET, POST, OPTIONS"),
132
+ ("Access-Control-Allow-Headers", "Content-Type"),
133
+ ("Access-Control-Max-Age", "86400")], b""
134
+ if method not in ("GET", "POST"):
135
+ return NOT_FOUND
136
+ if body is not None and len(body) > MAX_BODY:
137
+ return 413, [], b""
138
+ # The visitor's address: the collector needs it for the country, the
139
+ # daily visitor ID and the per-visitor rate limit.
140
+ out = {"X-Forwarded-For": client_ip or ""}
141
+ for name in PASS_HEADERS:
142
+ value = headers.get(name)
143
+ if value:
144
+ out[name.title()] = str(value).replace("\r", "").replace("\n", "")
145
+ url = self.core.endpoint + "/collectanalytics/" + rest + (("?" + query) if query else "")
146
+ request = urllib.request.Request(url, data=body if method == "POST" else None, headers=out, method=method)
147
+ try:
148
+ with urllib.request.urlopen(request, timeout=TIMEOUT) as answer:
149
+ return answer.status, self._answer_headers(answer.headers), answer.read(MAX_BODY)
150
+ except urllib.error.HTTPError as answer:
151
+ return answer.code, self._answer_headers(answer.headers), answer.read(MAX_BODY)
152
+ except Exception as error:
153
+ # The tracker already treats a failed beacon as lost and carries on.
154
+ self.core.log("proxy", "could not reach the collector: %s" % error)
155
+ return 502, [], b""
156
+
157
+ @staticmethod
158
+ def _answer_headers(headers):
159
+ out = []
160
+ for name in ("Content-Type", "Cache-Control", "Retry-After"):
161
+ value = headers.get(name)
162
+ if value:
163
+ out.append((name, value))
164
+ return out
165
+
166
+ def flush(self):
167
+ pass
168
+
169
+ def close(self):
170
+ pass
@@ -0,0 +1,175 @@
1
+ """Request rules (virtual patching; protocol in sdk/protocols/firewall.md):
2
+ the firewall has always matched addresses, these match the request -- its
3
+ method, path, query string and user agent -- so an attack is turned away
4
+ before the vulnerable code runs. They arrive with the other rules and are
5
+ compiled once per fetch; matching is plain string work on text we already
6
+ have, so it costs nothing per request.
7
+
8
+ The matching is the same in every package: the shared cases are in
9
+ sdk/protocols/request-rules-cases.json, and this module is run against them by
10
+ tests/test_request_rules.py.
11
+ """
12
+ import re
13
+ from urllib.parse import unquote_plus
14
+
15
+ MAX_RULES = 200
16
+ # How much of each part a rule may look at. Patterns come from our rules, but
17
+ # the text comes from whoever is asking, and `re` can't be timed out: a bounded
18
+ # slice keeps a bad pattern from being a hung request.
19
+ MAX_TEXT = 2000
20
+ MAX_REGEX = 200
21
+ SECTIONS = ("path", "query", "ua")
22
+
23
+
24
+ def _lower_list(entries, allow_empty=False):
25
+ return [e.lower() for e in entries if isinstance(e, str) and (allow_empty or e != "")]
26
+
27
+
28
+ def safe_pattern(pattern):
29
+ """A pattern can only come from us, but a careless one would be matched
30
+ against text an attacker chooses, in this very request, and `re` can't be
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."""
35
+ if not isinstance(pattern, str) or not pattern or len(pattern) > MAX_REGEX:
36
+ return False
37
+ if re.search(r"\\[1-9]", pattern) or "(?<" in pattern:
38
+ return False
39
+ open_at = []
40
+ i = 0
41
+ while i < len(pattern):
42
+ char = pattern[i]
43
+ if char == "\\":
44
+ i += 2
45
+ continue
46
+ if char == "[":
47
+ # A character class: quantifiers inside it are literals.
48
+ i += 1
49
+ while i < len(pattern) and pattern[i] != "]":
50
+ i += 2 if pattern[i] == "\\" else 1
51
+ i += 1
52
+ continue
53
+ if char == "(":
54
+ open_at.append(i)
55
+ elif char == ")":
56
+ if not open_at:
57
+ 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
64
+ i += 1
65
+ return not open_at
66
+
67
+
68
+ def _section(section):
69
+ if not isinstance(section, dict):
70
+ return None
71
+ out = {}
72
+ for matcher in ("exact", "prefix", "suffix", "contains"):
73
+ entries = section.get(matcher)
74
+ if not isinstance(entries, list):
75
+ continue
76
+ values = _lower_list(entries, allow_empty=matcher == "exact")
77
+ if values:
78
+ out[matcher] = values
79
+ pattern = section.get("regex")
80
+ if safe_pattern(pattern):
81
+ try:
82
+ out["regex"] = re.compile(pattern, re.IGNORECASE)
83
+ except re.error:
84
+ # A pattern we can't compile is dropped; the rule's other parts stay.
85
+ pass
86
+ return out or None
87
+
88
+
89
+ def compile_rules(data):
90
+ """The rules as they will be matched; [] when there are none."""
91
+ given = data.get("rules") if isinstance(data, dict) else None
92
+ rules = []
93
+ for rule in (given if isinstance(given, list) else [])[:MAX_RULES]:
94
+ if not isinstance(rule, dict) or not isinstance(rule.get("id"), str) or not rule["id"]:
95
+ continue
96
+ # We are called before the body is read, so a rule that needs the body
97
+ # is skipped whole rather than matched on the rest of it.
98
+ if "body" in rule:
99
+ continue
100
+ methods = rule.get("methods")
101
+ compiled = {
102
+ "id": rule["id"],
103
+ "action": "block" if rule.get("action") == "block" else "monitor",
104
+ "ban": int(rule["ban"]) if isinstance(rule.get("ban"), (int, float)) and int(rule["ban"]) > 0 else 0,
105
+ "methods": [m.upper() for m in methods if isinstance(m, str)] if isinstance(methods, list) else [],
106
+ }
107
+ sections = 0
108
+ for name in SECTIONS:
109
+ section = _section(rule.get(name))
110
+ if section is not None:
111
+ compiled[name] = section
112
+ sections += 1
113
+ # A rule with nothing to match would match everything.
114
+ if not sections:
115
+ continue
116
+ rules.append(compiled)
117
+ return rules
118
+
119
+
120
+ def _decode_once(text):
121
+ """One pass only, as the protocol says: enough for %2e%2e, and no risk of
122
+ decoding a visitor's own text into something that looks like an attack."""
123
+ if "%" not in text and "+" not in text:
124
+ return text
125
+ try:
126
+ return unquote_plus(text)
127
+ except Exception:
128
+ return text
129
+
130
+
131
+ def _normalize_path(path):
132
+ return re.sub(r"/{2,}", "/", path.lower().replace("\\", "/"))
133
+
134
+
135
+ def _hits(section, raw, decoded):
136
+ texts = (raw,) if decoded == raw else (raw, decoded)
137
+ for text in texts:
138
+ if text in section.get("exact", ()):
139
+ return True
140
+ if any(text.startswith(entry) for entry in section.get("prefix", ())):
141
+ return True
142
+ if any(entry and text.endswith(entry) for entry in section.get("suffix", ())):
143
+ return True
144
+ if any(entry and entry in text for entry in section.get("contains", ())):
145
+ return True
146
+ regex = section.get("regex")
147
+ if regex is not None and regex.search(text):
148
+ return True
149
+ return False
150
+
151
+
152
+ def match_request(rules, method="", path="", user_agent=""):
153
+ """The first rule that matches, or None. `path` is the request target as
154
+ the server received it, query string and all."""
155
+ if not rules:
156
+ return None
157
+ target = str(path or "")
158
+ mark = target.find("?")
159
+ 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()
161
+ ua = str(user_agent or "")[:MAX_TEXT].lower()
162
+ 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]
164
+ upper = str(method or "").upper()
165
+ for rule in rules:
166
+ if rule["methods"] and upper not in rule["methods"]:
167
+ continue
168
+ if "path" in rule and not _hits(rule["path"], raw_path, decoded_path):
169
+ continue
170
+ if "query" in rule and not _hits(rule["query"], raw_query, decoded_query):
171
+ continue
172
+ if "ua" in rule and not _hits(rule["ua"], ua, ua):
173
+ continue
174
+ return rule
175
+ return None