fastapi-guard 0.1.0__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.
- config/__init__.py +0 -0
- config/ip2/__init__.py +0 -0
- config/ip2/ip2location_config.py +162 -0
- config/sus_patterns.py +261 -0
- fastapi_guard-0.1.0.dist-info/LICENSE +21 -0
- fastapi_guard-0.1.0.dist-info/METADATA +296 -0
- fastapi_guard-0.1.0.dist-info/RECORD +16 -0
- fastapi_guard-0.1.0.dist-info/WHEEL +5 -0
- fastapi_guard-0.1.0.dist-info/top_level.txt +3 -0
- guard/__init__.py +5 -0
- guard/cloud_ips.py +110 -0
- guard/middleware.py +331 -0
- guard/models.py +234 -0
- guard/utils.py +321 -0
- tests/__init__.py +0 -0
- tests/conftest.py +53 -0
config/__init__.py
ADDED
|
File without changes
|
config/ip2/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from IP2Location import IP2Location
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import requests
|
|
7
|
+
import zipfile
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from guard.models import SecurityConfig
|
|
13
|
+
|
|
14
|
+
IP2_CONFIG_PATH = "./config/ip2/files"
|
|
15
|
+
DB_FILENAME = "IP2LOCATION-LITE-DB1.IPV6.BIN"
|
|
16
|
+
DOWNLOAD_URL = f"https://download.ip2location.com/lite/{DB_FILENAME}.ZIP"
|
|
17
|
+
VERSION_FILE = f"{IP2_CONFIG_PATH}/ip2location_version.txt"
|
|
18
|
+
|
|
19
|
+
ip2location_db = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_ip2location_database(
|
|
23
|
+
config: "SecurityConfig"
|
|
24
|
+
) -> IP2Location:
|
|
25
|
+
"""
|
|
26
|
+
Get the IP2Location database object.
|
|
27
|
+
"""
|
|
28
|
+
global ip2location_db
|
|
29
|
+
if ip2location_db is None:
|
|
30
|
+
db_path = config.ip2location_db_path or os.path.join(
|
|
31
|
+
IP2_CONFIG_PATH, DB_FILENAME
|
|
32
|
+
)
|
|
33
|
+
try:
|
|
34
|
+
ip2location_db = IP2Location(db_path)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
message = "Error loading IP2Location database"
|
|
37
|
+
reason_message = f"Reason: {str(e)}"
|
|
38
|
+
logging.error(f"{message} - {reason_message}")
|
|
39
|
+
ip2location_db = None
|
|
40
|
+
return ip2location_db
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def check_for_updates() -> bool:
|
|
44
|
+
"""
|
|
45
|
+
Check if there's a new version
|
|
46
|
+
of the IP2Location database available.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
bool: True if an update is
|
|
50
|
+
available, False otherwise.
|
|
51
|
+
"""
|
|
52
|
+
response = requests.head(DOWNLOAD_URL)
|
|
53
|
+
if response.status_code != 200:
|
|
54
|
+
message = "Failed to check for updates"
|
|
55
|
+
reason_message = f"Status code: {response.status_code}"
|
|
56
|
+
logging.error(f"{message} - {reason_message}")
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
last_modified = datetime.strptime(
|
|
60
|
+
response.headers[
|
|
61
|
+
"Last-Modified"
|
|
62
|
+
], "%a, %d %b %Y %H:%M:%S GMT"
|
|
63
|
+
).replace(tzinfo=timezone.utc)
|
|
64
|
+
|
|
65
|
+
if os.path.exists(VERSION_FILE):
|
|
66
|
+
with open(VERSION_FILE, "r") as f:
|
|
67
|
+
current_version = datetime.fromisoformat(
|
|
68
|
+
f.read().strip()
|
|
69
|
+
).replace(
|
|
70
|
+
tzinfo=timezone.utc
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if last_modified <= current_version:
|
|
74
|
+
print("Database is up to date.")
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
print("New version available. Updating...")
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def download_ip2location_database(
|
|
82
|
+
config: "SecurityConfig"
|
|
83
|
+
):
|
|
84
|
+
"""
|
|
85
|
+
Download and extract the latest IP2Location
|
|
86
|
+
database if an update is available.
|
|
87
|
+
"""
|
|
88
|
+
if not config.ip2location_auto_download:
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
if not check_for_updates():
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
response = requests.get(DOWNLOAD_URL)
|
|
95
|
+
|
|
96
|
+
if response.status_code == 200:
|
|
97
|
+
zip_path = os.path.join(IP2_CONFIG_PATH, f"{DB_FILENAME}.ZIP")
|
|
98
|
+
with open(zip_path, "wb") as f:
|
|
99
|
+
f.write(response.content)
|
|
100
|
+
|
|
101
|
+
with zipfile.ZipFile(zip_path, "r") as zip_ref:
|
|
102
|
+
zip_ref.extractall(IP2_CONFIG_PATH)
|
|
103
|
+
|
|
104
|
+
os.remove(zip_path)
|
|
105
|
+
|
|
106
|
+
with open(VERSION_FILE, "w") as f:
|
|
107
|
+
f.write(
|
|
108
|
+
datetime.now(
|
|
109
|
+
timezone.utc
|
|
110
|
+
).isoformat()
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
logging.info("IP2Location db downloaded successfully.")
|
|
114
|
+
else:
|
|
115
|
+
message = "Failed to download IP2Location database"
|
|
116
|
+
reason_message = f"Status code: {response.status_code}"
|
|
117
|
+
logging.error(f"{message} - {reason_message}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def periodic_update_check(
|
|
121
|
+
interval_hours: int = 24
|
|
122
|
+
):
|
|
123
|
+
"""
|
|
124
|
+
Periodically check for updates
|
|
125
|
+
and download the new database if available.
|
|
126
|
+
"""
|
|
127
|
+
while True:
|
|
128
|
+
try:
|
|
129
|
+
await asyncio.sleep(interval_hours * 3600)
|
|
130
|
+
if check_for_updates():
|
|
131
|
+
download_ip2location_database()
|
|
132
|
+
|
|
133
|
+
global ip2location_db
|
|
134
|
+
ip2location_db = None
|
|
135
|
+
except asyncio.CancelledError:
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def start_periodic_update_check(
|
|
140
|
+
config: "SecurityConfig"
|
|
141
|
+
):
|
|
142
|
+
"""
|
|
143
|
+
Start the periodic update
|
|
144
|
+
check in the background.
|
|
145
|
+
"""
|
|
146
|
+
if not config.ip2location_auto_update:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
task = asyncio.create_task(
|
|
150
|
+
periodic_update_check(
|
|
151
|
+
config.ip2location_update_interval
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def handle_task_done(future):
|
|
156
|
+
try:
|
|
157
|
+
future.result()
|
|
158
|
+
except asyncio.CancelledError:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
task.add_done_callback(handle_task_done)
|
|
162
|
+
return task
|
config/sus_patterns.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Set, List
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SusPatterns:
|
|
6
|
+
"""
|
|
7
|
+
A singleton class that manages suspicious
|
|
8
|
+
patterns for security checks.
|
|
9
|
+
|
|
10
|
+
This class maintains two sets of patterns:
|
|
11
|
+
default patterns and custom patterns.
|
|
12
|
+
It provides methods to add, remove,
|
|
13
|
+
and retrieve patterns.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
_instance = None
|
|
17
|
+
|
|
18
|
+
custom_patterns: Set[str] = set()
|
|
19
|
+
|
|
20
|
+
patterns: List[str] = [
|
|
21
|
+
# XSS
|
|
22
|
+
r"<script.*?>.*?</script.*?>",
|
|
23
|
+
r"javascript:",
|
|
24
|
+
r"onerror=",
|
|
25
|
+
r"onload=",
|
|
26
|
+
r"alert\(",
|
|
27
|
+
r"document\.cookie",
|
|
28
|
+
r"document\.write",
|
|
29
|
+
r"window\.location",
|
|
30
|
+
# SQL Injection
|
|
31
|
+
r"SELECT\s+.*\s+FROM\s+.*",
|
|
32
|
+
r"UNION\s+SELECT\s+.*",
|
|
33
|
+
r"'.*?OR.*?=.*?'",
|
|
34
|
+
r"'.*?AND.*?=.*?'",
|
|
35
|
+
r"INSERT\s+INTO\s+.*",
|
|
36
|
+
r"UPDATE\s+.*\s+SET\s+.*",
|
|
37
|
+
r"DELETE\s+FROM\s+.*",
|
|
38
|
+
r"DROP\s+TABLE\s+.*",
|
|
39
|
+
r"CREATE\s+TABLE\s+.*",
|
|
40
|
+
r"ALTER\s+TABLE\s+.*",
|
|
41
|
+
r"EXEC\s+.*",
|
|
42
|
+
r"CAST\s*\(.*\s+AS\s+.*\)",
|
|
43
|
+
r"CONVERT\s*\(.*\s+USING\s+.*\)",
|
|
44
|
+
# Directory Traversal
|
|
45
|
+
r"\.\./",
|
|
46
|
+
r"\.\.\\",
|
|
47
|
+
r"/etc/passwd",
|
|
48
|
+
r"/etc/shadow",
|
|
49
|
+
r"/etc/group",
|
|
50
|
+
r"/proc/self/environ",
|
|
51
|
+
r"/windows/win.ini",
|
|
52
|
+
r"/boot.ini",
|
|
53
|
+
# Command Injection
|
|
54
|
+
r"\b(?:ls|cat|rm|mv|cp|chmod|chown|sudo|su)\b",
|
|
55
|
+
r"\b(?:wget|curl|nc|ncat|telnet|ssh|ftp)\b",
|
|
56
|
+
r"\b(?:ping|traceroute|nslookup|dig)\b",
|
|
57
|
+
r"\b(?:ifconfig|ipconfig|netstat)\b",
|
|
58
|
+
r"\b(?:uname|whoami|id|pwd)\b",
|
|
59
|
+
# Sensitive File Access
|
|
60
|
+
r"\b(?:passwd|shadow|group)\b",
|
|
61
|
+
r"\b(?:\.env|\.git|\.svn|\.hg|\.DS_Store)\b",
|
|
62
|
+
r"\b(?:phpinfo|setup\.php|config\.php|admin\.php)\b",
|
|
63
|
+
r"\b(?:sitemap\.xml|robots\.txt|security\.txt)\b",
|
|
64
|
+
# Common Admin Paths
|
|
65
|
+
r"\b(?:solr|admin|cgi-bin|wp-admin|wp-login)\b",
|
|
66
|
+
# Common Query Parameters
|
|
67
|
+
r"\b(?:query|show|diagnostics|status|action)\b",
|
|
68
|
+
r"\b(?:format=json|wt=json)\b",
|
|
69
|
+
# HTTP Method Tampering
|
|
70
|
+
r"OPTIONS",
|
|
71
|
+
r"TRACE",
|
|
72
|
+
r"CONNECT",
|
|
73
|
+
# Path Traversal
|
|
74
|
+
r"\.\./",
|
|
75
|
+
r"\.\.\\",
|
|
76
|
+
# File Inclusion
|
|
77
|
+
r"file://",
|
|
78
|
+
r"php://",
|
|
79
|
+
r"data://",
|
|
80
|
+
r"zip://",
|
|
81
|
+
r"rar://",
|
|
82
|
+
r"expect://",
|
|
83
|
+
# LDAP Injection
|
|
84
|
+
r"\(\|\(.*?\=\*\)\)",
|
|
85
|
+
r"\(\&\(.*?\=\*\)\)",
|
|
86
|
+
# XML Injection
|
|
87
|
+
r"<!DOCTYPE\s+.*?>",
|
|
88
|
+
r"<\?xml\s+.*?>",
|
|
89
|
+
r"<!ENTITY\s+.*?>",
|
|
90
|
+
# SSRF (Server-Side Request Forgery)
|
|
91
|
+
r"http://localhost",
|
|
92
|
+
r"http://127\.0\.0\.1",
|
|
93
|
+
r"http://169\.254\.169\.254",
|
|
94
|
+
r"http://metadata\.google\.internal",
|
|
95
|
+
# Open Redirect
|
|
96
|
+
r"//",
|
|
97
|
+
r"/\.\./",
|
|
98
|
+
r"/\.\.\\",
|
|
99
|
+
# CRLF Injection
|
|
100
|
+
r"%0d%0a",
|
|
101
|
+
r"%0d",
|
|
102
|
+
r"%0a",
|
|
103
|
+
# Path Manipulation
|
|
104
|
+
r"\.\./",
|
|
105
|
+
r"\.\.\\",
|
|
106
|
+
# Shell Injection
|
|
107
|
+
r";",
|
|
108
|
+
r"&",
|
|
109
|
+
r"\|",
|
|
110
|
+
r"`",
|
|
111
|
+
r"\$\(.*?\)",
|
|
112
|
+
r"\$\{.*?\}",
|
|
113
|
+
# NoSQL Injection
|
|
114
|
+
r"\{\s*['\"]?\$.*?['\"]?\s*:\s*.*?\s*\}",
|
|
115
|
+
# JSON Injection
|
|
116
|
+
r"\{\s*\"\$.*?\"\s*:\s*.*?\s*\}",
|
|
117
|
+
# HTTP Header Injection
|
|
118
|
+
r"\r\n",
|
|
119
|
+
r"\n",
|
|
120
|
+
# File Upload
|
|
121
|
+
r"Content-Disposition: form-data; name=\".*?\"; filename=\".*?\."
|
|
122
|
+
r"(php|exe|sh|bat)\"",
|
|
123
|
+
# Other
|
|
124
|
+
r"eval\(",
|
|
125
|
+
r"base64_decode\(",
|
|
126
|
+
r"system\(",
|
|
127
|
+
r"shell_exec\(",
|
|
128
|
+
r"exec\(",
|
|
129
|
+
r"popen\(",
|
|
130
|
+
r"proc_open\(",
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
def __new__(cls):
|
|
134
|
+
"""
|
|
135
|
+
Ensure only one instance of SusPatterns
|
|
136
|
+
is created (singleton pattern).
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
SusPatterns: The single instance
|
|
140
|
+
of the SusPatterns class.
|
|
141
|
+
"""
|
|
142
|
+
if cls._instance is None:
|
|
143
|
+
cls._instance = super(
|
|
144
|
+
SusPatterns,
|
|
145
|
+
cls
|
|
146
|
+
).__new__(cls)
|
|
147
|
+
cls._instance.compiled_patterns = [
|
|
148
|
+
re.compile(
|
|
149
|
+
pattern,
|
|
150
|
+
re.IGNORECASE
|
|
151
|
+
)
|
|
152
|
+
for pattern in cls.patterns
|
|
153
|
+
]
|
|
154
|
+
cls._instance.compiled_custom_patterns = set()
|
|
155
|
+
return cls._instance
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
async def add_pattern(
|
|
159
|
+
cls,
|
|
160
|
+
pattern: str,
|
|
161
|
+
custom: bool = False
|
|
162
|
+
) -> None:
|
|
163
|
+
"""
|
|
164
|
+
Add a new pattern to either the custom or
|
|
165
|
+
default patterns list.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
pattern (str): The pattern to be added.
|
|
169
|
+
custom (bool, optional): If True, add
|
|
170
|
+
to custom patterns; otherwise, add to
|
|
171
|
+
default patterns. Defaults to False.
|
|
172
|
+
"""
|
|
173
|
+
compiled_pattern = re.compile(
|
|
174
|
+
pattern,
|
|
175
|
+
re.IGNORECASE
|
|
176
|
+
)
|
|
177
|
+
if custom:
|
|
178
|
+
cls._instance.compiled_custom_patterns.add(
|
|
179
|
+
compiled_pattern
|
|
180
|
+
)
|
|
181
|
+
cls._instance.custom_patterns.add(
|
|
182
|
+
pattern
|
|
183
|
+
)
|
|
184
|
+
else:
|
|
185
|
+
cls._instance.compiled_patterns.append(
|
|
186
|
+
compiled_pattern
|
|
187
|
+
)
|
|
188
|
+
cls._instance.patterns.append(
|
|
189
|
+
pattern
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
async def remove_pattern(
|
|
194
|
+
cls,
|
|
195
|
+
pattern: str,
|
|
196
|
+
custom: bool = False
|
|
197
|
+
) -> None:
|
|
198
|
+
"""
|
|
199
|
+
Remove a pattern from either the
|
|
200
|
+
custom or default patterns list.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
pattern (str): The pattern to be removed.
|
|
204
|
+
custom (bool, optional): If True, remove
|
|
205
|
+
from custom patterns; otherwise, remove
|
|
206
|
+
from default patterns. Defaults to False.
|
|
207
|
+
"""
|
|
208
|
+
compiled_pattern = re.compile(
|
|
209
|
+
pattern,
|
|
210
|
+
re.IGNORECASE
|
|
211
|
+
)
|
|
212
|
+
if custom:
|
|
213
|
+
cls._instance.compiled_custom_patterns.discard(
|
|
214
|
+
compiled_pattern
|
|
215
|
+
)
|
|
216
|
+
cls._instance.custom_patterns.discard(
|
|
217
|
+
pattern
|
|
218
|
+
)
|
|
219
|
+
else:
|
|
220
|
+
cls._instance.compiled_patterns = [
|
|
221
|
+
p
|
|
222
|
+
for p in cls._instance.compiled_patterns
|
|
223
|
+
if p.pattern != pattern
|
|
224
|
+
]
|
|
225
|
+
cls._instance.patterns = [
|
|
226
|
+
p
|
|
227
|
+
for p in cls._instance.patterns
|
|
228
|
+
if p != pattern
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
async def get_all_patterns(
|
|
233
|
+
cls
|
|
234
|
+
) -> List[str]:
|
|
235
|
+
"""
|
|
236
|
+
Retrieve all patterns, including
|
|
237
|
+
both default and custom patterns.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
List[str]: A list containing
|
|
241
|
+
all default and custom patterns.
|
|
242
|
+
"""
|
|
243
|
+
return cls._instance.patterns + list(
|
|
244
|
+
cls._instance.custom_patterns
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
@classmethod
|
|
248
|
+
async def get_all_compiled_patterns(
|
|
249
|
+
cls
|
|
250
|
+
) -> List[re.Pattern]:
|
|
251
|
+
"""
|
|
252
|
+
Retrieve all compiled patterns,
|
|
253
|
+
including both default and custom patterns.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
List[re.Pattern]: A list containing
|
|
257
|
+
all default and custom compiled patterns.
|
|
258
|
+
"""
|
|
259
|
+
return cls._instance.compiled_patterns + list(
|
|
260
|
+
cls._instance.compiled_custom_patterns
|
|
261
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Renzo F
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|