zen-ai-pentest 2.2.0__py3-none-any.whl → 2.3.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.
- api/auth.py +61 -7
- api/csrf_protection.py +286 -0
- api/main.py +77 -11
- api/rate_limiter.py +317 -0
- api/rate_limiter_v2.py +586 -0
- autonomous/ki_analysis_agent.py +1033 -0
- benchmarks/__init__.py +12 -142
- benchmarks/agent_performance.py +374 -0
- benchmarks/api_performance.py +479 -0
- benchmarks/scan_performance.py +272 -0
- modules/agent_coordinator.py +255 -0
- modules/api_key_manager.py +501 -0
- modules/benchmark.py +706 -0
- modules/cve_updater.py +303 -0
- modules/false_positive_filter.py +149 -0
- modules/output_formats.py +1088 -0
- modules/risk_scoring.py +206 -0
- {zen_ai_pentest-2.2.0.dist-info → zen_ai_pentest-2.3.0.dist-info}/METADATA +134 -289
- {zen_ai_pentest-2.2.0.dist-info → zen_ai_pentest-2.3.0.dist-info}/RECORD +23 -9
- {zen_ai_pentest-2.2.0.dist-info → zen_ai_pentest-2.3.0.dist-info}/WHEEL +0 -0
- {zen_ai_pentest-2.2.0.dist-info → zen_ai_pentest-2.3.0.dist-info}/entry_points.txt +0 -0
- {zen_ai_pentest-2.2.0.dist-info → zen_ai_pentest-2.3.0.dist-info}/licenses/LICENSE +0 -0
- {zen_ai_pentest-2.2.0.dist-info → zen_ai_pentest-2.3.0.dist-info}/top_level.txt +0 -0
api/auth.py
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
"""
|
|
2
2
|
JWT Authentication für Zen-AI-Pentest API
|
|
3
|
+
|
|
4
|
+
SECURITY NOTES:
|
|
5
|
+
- All secrets are loaded from environment variables
|
|
6
|
+
- Never commit actual secrets to version control
|
|
7
|
+
- Use .env file locally, proper secret management in production
|
|
3
8
|
"""
|
|
4
9
|
|
|
10
|
+
import os
|
|
11
|
+
import secrets
|
|
5
12
|
from datetime import datetime, timedelta
|
|
6
13
|
from typing import Optional, Dict
|
|
7
14
|
from jose import JWTError, jwt
|
|
@@ -9,23 +16,47 @@ from passlib.context import CryptContext
|
|
|
9
16
|
from fastapi import Depends, HTTPException, status
|
|
10
17
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
11
18
|
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
# =============================================================================
|
|
20
|
+
# Configuration - Load from environment variables
|
|
21
|
+
# =============================================================================
|
|
22
|
+
|
|
23
|
+
# JWT Configuration
|
|
24
|
+
SECRET_KEY = os.getenv("JWT_SECRET_KEY")
|
|
25
|
+
if not SECRET_KEY or SECRET_KEY == "your-super-secret-jwt-key-change-this-in-production":
|
|
26
|
+
# Generate a random key for development (not for production!)
|
|
27
|
+
import warnings
|
|
28
|
+
warnings.warn(
|
|
29
|
+
"JWT_SECRET_KEY not set or using default! Using random key for development. "
|
|
30
|
+
"Set JWT_SECRET_KEY environment variable for production!",
|
|
31
|
+
RuntimeWarning
|
|
32
|
+
)
|
|
33
|
+
SECRET_KEY = secrets.token_hex(32)
|
|
34
|
+
|
|
35
|
+
ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
|
|
36
|
+
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
|
|
16
37
|
|
|
17
38
|
# Password hashing
|
|
18
39
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
19
40
|
security = HTTPBearer()
|
|
20
41
|
|
|
42
|
+
# =============================================================================
|
|
43
|
+
# Password Functions
|
|
44
|
+
# =============================================================================
|
|
45
|
+
|
|
21
46
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
22
47
|
"""Verifiziert Passwort"""
|
|
23
48
|
return pwd_context.verify(plain_password, hashed_password)
|
|
24
49
|
|
|
50
|
+
|
|
25
51
|
def get_password_hash(password: str) -> str:
|
|
26
52
|
"""Hashed Passwort"""
|
|
27
53
|
return pwd_context.hash(password)
|
|
28
54
|
|
|
55
|
+
|
|
56
|
+
# =============================================================================
|
|
57
|
+
# JWT Token Functions
|
|
58
|
+
# =============================================================================
|
|
59
|
+
|
|
29
60
|
def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -> str:
|
|
30
61
|
"""Erstellt JWT Token"""
|
|
31
62
|
to_encode = data.copy()
|
|
@@ -34,6 +65,7 @@ def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -
|
|
|
34
65
|
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
|
35
66
|
return encoded_jwt
|
|
36
67
|
|
|
68
|
+
|
|
37
69
|
def decode_token(token: str) -> Optional[Dict]:
|
|
38
70
|
"""Decodiert JWT Token"""
|
|
39
71
|
try:
|
|
@@ -42,6 +74,11 @@ def decode_token(token: str) -> Optional[Dict]:
|
|
|
42
74
|
except JWTError:
|
|
43
75
|
return None
|
|
44
76
|
|
|
77
|
+
|
|
78
|
+
# =============================================================================
|
|
79
|
+
# FastAPI Dependencies
|
|
80
|
+
# =============================================================================
|
|
81
|
+
|
|
45
82
|
async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict:
|
|
46
83
|
"""FastAPI Dependency für Token-Verifizierung"""
|
|
47
84
|
token = credentials.credentials
|
|
@@ -65,6 +102,7 @@ async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(secur
|
|
|
65
102
|
|
|
66
103
|
return payload
|
|
67
104
|
|
|
105
|
+
|
|
68
106
|
def check_permissions(user: Dict, required_role: str) -> bool:
|
|
69
107
|
"""Prüft ob User die benötigte Rolle hat"""
|
|
70
108
|
user_role = user.get("role", "viewer")
|
|
@@ -80,6 +118,7 @@ def check_permissions(user: Dict, required_role: str) -> bool:
|
|
|
80
118
|
|
|
81
119
|
return user_level >= required_level
|
|
82
120
|
|
|
121
|
+
|
|
83
122
|
async def require_admin(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict:
|
|
84
123
|
"""Erfordert Admin-Rolle"""
|
|
85
124
|
user = await verify_token(credentials)
|
|
@@ -92,6 +131,7 @@ async def require_admin(credentials: HTTPAuthorizationCredentials = Depends(secu
|
|
|
92
131
|
|
|
93
132
|
return user
|
|
94
133
|
|
|
134
|
+
|
|
95
135
|
async def require_operator(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict:
|
|
96
136
|
"""Erfordert Operator oder höhere Rolle"""
|
|
97
137
|
user = await verify_token(credentials)
|
|
@@ -104,16 +144,22 @@ async def require_operator(credentials: HTTPAuthorizationCredentials = Depends(s
|
|
|
104
144
|
|
|
105
145
|
return user
|
|
106
146
|
|
|
107
|
-
|
|
108
|
-
|
|
147
|
+
|
|
148
|
+
# =============================================================================
|
|
149
|
+
# API Key Authentication
|
|
150
|
+
# =============================================================================
|
|
151
|
+
|
|
152
|
+
# In-memory store for API keys (use database in production!)
|
|
153
|
+
API_KEYS: Dict[str, Dict] = {}
|
|
154
|
+
|
|
109
155
|
|
|
110
156
|
def verify_api_key(api_key: str) -> Optional[Dict]:
|
|
111
157
|
"""Verifiziert API Key"""
|
|
112
158
|
return API_KEYS.get(api_key)
|
|
113
159
|
|
|
160
|
+
|
|
114
161
|
def create_api_key(user_id: int, name: str) -> str:
|
|
115
162
|
"""Erstellt neuen API Key"""
|
|
116
|
-
import secrets
|
|
117
163
|
key = secrets.token_urlsafe(32)
|
|
118
164
|
API_KEYS[key] = {
|
|
119
165
|
"user_id": user_id,
|
|
@@ -121,3 +167,11 @@ def create_api_key(user_id: int, name: str) -> str:
|
|
|
121
167
|
"created_at": datetime.utcnow().isoformat()
|
|
122
168
|
}
|
|
123
169
|
return key
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def revoke_api_key(api_key: str) -> bool:
|
|
173
|
+
"""Widerruft API Key"""
|
|
174
|
+
if api_key in API_KEYS:
|
|
175
|
+
del API_KEYS[api_key]
|
|
176
|
+
return True
|
|
177
|
+
return False
|
api/csrf_protection.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CSRF Protection für Zen-AI-Pentest API
|
|
3
|
+
|
|
4
|
+
Implementiert Double-Submit-Cookie Pattern:
|
|
5
|
+
1. Server setzt CSRF-Token als Cookie
|
|
6
|
+
2. Client muss Token im Header zurücksenden
|
|
7
|
+
3. Server vergleicht Cookie vs Header
|
|
8
|
+
|
|
9
|
+
Schützt vor:
|
|
10
|
+
- Cross-Site Request Forgery
|
|
11
|
+
- Session Riding
|
|
12
|
+
- Unautorisierte POST/PUT/DELETE Requests
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import secrets
|
|
17
|
+
import hashlib
|
|
18
|
+
import hmac
|
|
19
|
+
from typing import Optional, Dict
|
|
20
|
+
from datetime import datetime, timedelta
|
|
21
|
+
from fastapi import Request, HTTPException, status
|
|
22
|
+
from fastapi.responses import Response
|
|
23
|
+
import logging
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# =============================================================================
|
|
28
|
+
# Configuration
|
|
29
|
+
# =============================================================================
|
|
30
|
+
|
|
31
|
+
CSRF_COOKIE_NAME = "csrf_token"
|
|
32
|
+
CSRF_HEADER_NAME = "X-CSRF-Token"
|
|
33
|
+
CSRF_COOKIE_SECURE = os.getenv("CSRF_COOKIE_SECURE", "false").lower() == "true"
|
|
34
|
+
CSRF_COOKIE_SAMESITE = os.getenv("CSRF_COOKIE_SAMESITE", "Lax")
|
|
35
|
+
CSRF_TOKEN_EXPIRY = int(os.getenv("CSRF_TOKEN_EXPIRY", "86400")) # 24 hours
|
|
36
|
+
|
|
37
|
+
# =============================================================================
|
|
38
|
+
# CSRF Token Management
|
|
39
|
+
# =============================================================================
|
|
40
|
+
|
|
41
|
+
class CSRFToken:
|
|
42
|
+
"""CSRF Token mit Zeitstempel und Validierung"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, token: str = None, timestamp: datetime = None):
|
|
45
|
+
self.token = token or self._generate_token()
|
|
46
|
+
self.timestamp = timestamp or datetime.utcnow()
|
|
47
|
+
|
|
48
|
+
def _generate_token(self) -> str:
|
|
49
|
+
"""Generiert kryptographisch sicheres Token"""
|
|
50
|
+
return secrets.token_urlsafe(32)
|
|
51
|
+
|
|
52
|
+
def is_valid(self) -> bool:
|
|
53
|
+
"""Prüft ob Token noch gültig ist"""
|
|
54
|
+
expiry = self.timestamp + timedelta(seconds=CSRF_TOKEN_EXPIRY)
|
|
55
|
+
return datetime.utcnow() < expiry
|
|
56
|
+
|
|
57
|
+
def to_cookie_value(self) -> str:
|
|
58
|
+
"""Formatiert Token für Cookie"""
|
|
59
|
+
return f"{self.token}|{int(self.timestamp.timestamp())}"
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_cookie_value(cls, cookie_value: str) -> Optional["CSRFToken"]:
|
|
63
|
+
"""Parst Token aus Cookie"""
|
|
64
|
+
try:
|
|
65
|
+
parts = cookie_value.split("|")
|
|
66
|
+
if len(parts) != 2:
|
|
67
|
+
return None
|
|
68
|
+
token, timestamp = parts
|
|
69
|
+
dt = datetime.fromtimestamp(int(timestamp))
|
|
70
|
+
return cls(token, dt)
|
|
71
|
+
except (ValueError, TypeError):
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# =============================================================================
|
|
76
|
+
# CSRF Protection Middleware
|
|
77
|
+
# =============================================================================
|
|
78
|
+
|
|
79
|
+
class CSRFProtection:
|
|
80
|
+
"""
|
|
81
|
+
CSRF Protection Middleware
|
|
82
|
+
|
|
83
|
+
Usage:
|
|
84
|
+
from api.csrf_protection import CSRFProtection
|
|
85
|
+
csrf = CSRFProtection()
|
|
86
|
+
|
|
87
|
+
@app.get("/csrf-token")
|
|
88
|
+
async def get_csrf_token(response: Response):
|
|
89
|
+
return csrf.set_token(response)
|
|
90
|
+
|
|
91
|
+
@app.post("/api/action")
|
|
92
|
+
async def protected_action(request: Request):
|
|
93
|
+
csrf.validate(request) # Wirft 403 falls ungültig
|
|
94
|
+
return {"status": "ok"}
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
# Endpoints die kein CSRF benötigen (z.B. Login, API Keys)
|
|
98
|
+
EXEMPT_ENDPOINTS = [
|
|
99
|
+
"/auth/login",
|
|
100
|
+
"/auth/logout",
|
|
101
|
+
"/auth/refresh",
|
|
102
|
+
"/api/webhook", # Webhooks kommen von extern
|
|
103
|
+
"/health",
|
|
104
|
+
"/docs",
|
|
105
|
+
"/openapi.json",
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
# HTTP Methoden die CSRF-Schutz benötigen
|
|
109
|
+
PROTECTED_METHODS = ["POST", "PUT", "PATCH", "DELETE"]
|
|
110
|
+
|
|
111
|
+
def __init__(self):
|
|
112
|
+
self.tokens: Dict[str, CSRFToken] = {} # Session -> Token
|
|
113
|
+
|
|
114
|
+
def set_token(self, response: Response, session_id: str = None) -> Dict:
|
|
115
|
+
"""
|
|
116
|
+
Setzt neuen CSRF Token als Cookie
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Dict mit Token für Client
|
|
120
|
+
"""
|
|
121
|
+
token = CSRFToken()
|
|
122
|
+
|
|
123
|
+
# Cookie setzen
|
|
124
|
+
cookie_value = token.to_cookie_value()
|
|
125
|
+
response.set_cookie(
|
|
126
|
+
key=CSRF_COOKIE_NAME,
|
|
127
|
+
value=cookie_value,
|
|
128
|
+
httponly=False, # Muss von JavaScript lesbar sein
|
|
129
|
+
secure=CSRF_COOKIE_SECURE,
|
|
130
|
+
samesite=CSRF_COOKIE_SAMESITE,
|
|
131
|
+
max_age=CSRF_TOKEN_EXPIRY,
|
|
132
|
+
path="/"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# In Speicher merken (für Validierung)
|
|
136
|
+
if session_id:
|
|
137
|
+
self.tokens[session_id] = token
|
|
138
|
+
|
|
139
|
+
logger.debug(f"CSRF Token gesetzt für Session: {session_id}")
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
"csrf_token": token.token,
|
|
143
|
+
"expires_in": CSRF_TOKEN_EXPIRY
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
def validate(self, request: Request) -> bool:
|
|
147
|
+
"""
|
|
148
|
+
Validiert CSRF Token aus Request
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
HTTPException: 403 falls Token ungültig/fehlt
|
|
152
|
+
"""
|
|
153
|
+
# Prüfe ob Endpoint exempt ist
|
|
154
|
+
path = request.url.path
|
|
155
|
+
method = request.method
|
|
156
|
+
|
|
157
|
+
if method not in self.PROTECTED_METHODS:
|
|
158
|
+
return True # GET, HEAD, OPTIONS, TRACE brauchen kein CSRF
|
|
159
|
+
|
|
160
|
+
if any(path.startswith(exempt) for exempt in self.EXEMPT_ENDPOINTS):
|
|
161
|
+
return True # Exempt Endpoints
|
|
162
|
+
|
|
163
|
+
# Token aus Cookie holen
|
|
164
|
+
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
|
|
165
|
+
if not cookie_token:
|
|
166
|
+
logger.warning(f"CSRF Cookie fehlt: {path}")
|
|
167
|
+
raise HTTPException(
|
|
168
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
169
|
+
detail="CSRF token missing. Get a token from /csrf-token first."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Token parsen und validieren
|
|
173
|
+
csrf_token = CSRFToken.from_cookie_value(cookie_token)
|
|
174
|
+
if not csrf_token or not csrf_token.is_valid():
|
|
175
|
+
logger.warning(f"CSRF Token abgelaufen oder ungültig: {path}")
|
|
176
|
+
raise HTTPException(
|
|
177
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
178
|
+
detail="CSRF token expired or invalid."
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Token aus Header holen
|
|
182
|
+
header_token = request.headers.get(CSRF_HEADER_NAME)
|
|
183
|
+
if not header_token:
|
|
184
|
+
logger.warning(f"CSRF Header fehlt: {path}")
|
|
185
|
+
raise HTTPException(
|
|
186
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
187
|
+
detail=f"CSRF header '{CSRF_HEADER_NAME}' required."
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Tokens vergleichen (timing-safe)
|
|
191
|
+
if not hmac.compare_digest(csrf_token.token, header_token):
|
|
192
|
+
logger.warning(f"CSRF Token mismatch: {path}")
|
|
193
|
+
raise HTTPException(
|
|
194
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
195
|
+
detail="CSRF token mismatch."
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
logger.debug(f"CSRF Token validiert: {path}")
|
|
199
|
+
return True
|
|
200
|
+
|
|
201
|
+
def rotate_token(self, response: Response, session_id: str = None) -> Dict:
|
|
202
|
+
"""
|
|
203
|
+
Rotiert CSRF Token (nach Login oder periodisch)
|
|
204
|
+
"""
|
|
205
|
+
# Altes Token entfernen
|
|
206
|
+
if session_id and session_id in self.tokens:
|
|
207
|
+
del self.tokens[session_id]
|
|
208
|
+
|
|
209
|
+
# Neues Token setzen
|
|
210
|
+
return self.set_token(response, session_id)
|
|
211
|
+
|
|
212
|
+
def clear_token(self, response: Response, session_id: str = None):
|
|
213
|
+
"""
|
|
214
|
+
Löscht CSRF Token (bei Logout)
|
|
215
|
+
"""
|
|
216
|
+
response.delete_cookie(
|
|
217
|
+
key=CSRF_COOKIE_NAME,
|
|
218
|
+
path="/"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if session_id and session_id in self.tokens:
|
|
222
|
+
del self.tokens[session_id]
|
|
223
|
+
|
|
224
|
+
logger.debug(f"CSRF Token gelöscht für Session: {session_id}")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# =============================================================================
|
|
228
|
+
# FastAPI Dependency
|
|
229
|
+
# =============================================================================
|
|
230
|
+
|
|
231
|
+
csrf_protection = CSRFProtection()
|
|
232
|
+
|
|
233
|
+
async def require_csrf(request: Request):
|
|
234
|
+
"""
|
|
235
|
+
FastAPI Dependency für CSRF-Schutz
|
|
236
|
+
|
|
237
|
+
Usage:
|
|
238
|
+
@app.post("/api/action")
|
|
239
|
+
async def action(request: Request, _=Depends(require_csrf)):
|
|
240
|
+
return {"status": "ok"}
|
|
241
|
+
"""
|
|
242
|
+
csrf_protection.validate(request)
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# =============================================================================
|
|
247
|
+
# Middleware für automatischen CSRF-Schutz
|
|
248
|
+
# =============================================================================
|
|
249
|
+
|
|
250
|
+
class CSRFMiddleware:
|
|
251
|
+
"""
|
|
252
|
+
ASGI Middleware für automatischen CSRF-Schutz
|
|
253
|
+
|
|
254
|
+
Usage:
|
|
255
|
+
from api.csrf_protection import CSRFMiddleware
|
|
256
|
+
app.add_middleware(CSRFMiddleware)
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
def __init__(self, app):
|
|
260
|
+
self.app = app
|
|
261
|
+
self.csrf = CSRFProtection()
|
|
262
|
+
|
|
263
|
+
async def __call__(self, scope, receive, send):
|
|
264
|
+
if scope["type"] != "http":
|
|
265
|
+
await self.app(scope, receive, send)
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
# Request prüfen
|
|
269
|
+
request = Request(scope, receive)
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
self.csrf.validate(request)
|
|
273
|
+
except HTTPException as e:
|
|
274
|
+
# Fehler direkt zurückgeben
|
|
275
|
+
await send({
|
|
276
|
+
"type": "http.response.start",
|
|
277
|
+
"status": e.status_code,
|
|
278
|
+
"headers": [[b"content-type", b"application/json"]]
|
|
279
|
+
})
|
|
280
|
+
await send({
|
|
281
|
+
"type": "http.response.body",
|
|
282
|
+
"body": f'"{e.detail}"'.encode()
|
|
283
|
+
})
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
await self.app(scope, receive, send)
|
api/main.py
CHANGED
|
@@ -11,7 +11,7 @@ from pathlib import Path
|
|
|
11
11
|
# Add parent to path
|
|
12
12
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
13
13
|
|
|
14
|
-
from fastapi import FastAPI, Depends, HTTPException, BackgroundTasks, WebSocket, WebSocketDisconnect
|
|
14
|
+
from fastapi import FastAPI, Depends, HTTPException, BackgroundTasks, WebSocket, WebSocketDisconnect, Request, Response
|
|
15
15
|
from fastapi.middleware.cors import CORSMiddleware
|
|
16
16
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
17
17
|
from contextlib import asynccontextmanager
|
|
@@ -36,6 +36,13 @@ from api.schemas import (
|
|
|
36
36
|
)
|
|
37
37
|
from api.auth import verify_token, create_access_token
|
|
38
38
|
from api.websocket import ConnectionManager
|
|
39
|
+
from api.rate_limiter import (
|
|
40
|
+
check_auth_rate_limit,
|
|
41
|
+
record_auth_failure,
|
|
42
|
+
record_auth_success,
|
|
43
|
+
rate_limit
|
|
44
|
+
)
|
|
45
|
+
from api.csrf_protection import csrf_protection, require_csrf
|
|
39
46
|
|
|
40
47
|
logging.basicConfig(level=logging.INFO)
|
|
41
48
|
logger = logging.getLogger(__name__)
|
|
@@ -64,26 +71,73 @@ app = FastAPI(
|
|
|
64
71
|
lifespan=lifespan
|
|
65
72
|
)
|
|
66
73
|
|
|
67
|
-
#
|
|
74
|
+
# =============================================================================
|
|
75
|
+
# CORS Configuration
|
|
76
|
+
# =============================================================================
|
|
77
|
+
# Load allowed origins from environment variable
|
|
78
|
+
# Format: comma-separated list of origins
|
|
79
|
+
# Example: CORS_ORIGINS=https://domain.com,https://app.domain.com
|
|
80
|
+
cors_origins_str = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://localhost:8000")
|
|
81
|
+
ALLOWED_ORIGINS = [origin.strip() for origin in cors_origins_str.split(",")]
|
|
82
|
+
|
|
68
83
|
app.add_middleware(
|
|
69
84
|
CORSMiddleware,
|
|
70
|
-
allow_origins=
|
|
85
|
+
allow_origins=ALLOWED_ORIGINS,
|
|
71
86
|
allow_credentials=True,
|
|
72
|
-
allow_methods=["
|
|
73
|
-
allow_headers=["
|
|
87
|
+
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
88
|
+
allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
|
|
89
|
+
expose_headers=["X-Request-ID"],
|
|
90
|
+
max_age=600, # 10 minutes cache for preflight
|
|
74
91
|
)
|
|
75
92
|
|
|
76
93
|
# ============================================================================
|
|
77
94
|
# AUTHENTICATION
|
|
78
95
|
# ============================================================================
|
|
79
96
|
|
|
97
|
+
# =============================================================================
|
|
98
|
+
# Secure Credential Store (In production: use database!)
|
|
99
|
+
# =============================================================================
|
|
100
|
+
# Load admin credentials from environment variables
|
|
101
|
+
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
|
|
102
|
+
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD")
|
|
103
|
+
|
|
104
|
+
if not ADMIN_PASSWORD:
|
|
105
|
+
import warnings
|
|
106
|
+
warnings.warn(
|
|
107
|
+
"ADMIN_PASSWORD not set! Using insecure default for development only. "
|
|
108
|
+
"Set ADMIN_PASSWORD environment variable for production!",
|
|
109
|
+
RuntimeWarning
|
|
110
|
+
)
|
|
111
|
+
ADMIN_PASSWORD = "admin" # Only for development!
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def verify_admin_credentials(username: str, password: str) -> bool:
|
|
115
|
+
"""Securely verify admin credentials using constant-time comparison"""
|
|
116
|
+
import hmac
|
|
117
|
+
return hmac.compare_digest(username, ADMIN_USERNAME) and \
|
|
118
|
+
hmac.compare_digest(password, ADMIN_PASSWORD)
|
|
119
|
+
|
|
120
|
+
|
|
80
121
|
@app.post("/auth/login")
|
|
81
|
-
async def login(credentials: dict):
|
|
82
|
-
"""Login and get JWT token"""
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
122
|
+
async def login(credentials: dict, request: Request):
|
|
123
|
+
"""Login and get JWT token with secure credential verification and rate limiting"""
|
|
124
|
+
client_ip = request.client.host if request.client else "unknown"
|
|
125
|
+
|
|
126
|
+
# Check auth rate limit
|
|
127
|
+
check_auth_rate_limit(client_ip)
|
|
128
|
+
|
|
129
|
+
username = credentials.get("username", "")
|
|
130
|
+
password = credentials.get("password", "")
|
|
131
|
+
|
|
132
|
+
if verify_admin_credentials(username, password):
|
|
133
|
+
record_auth_success(client_ip)
|
|
134
|
+
token = create_access_token({"sub": username, "role": "admin"})
|
|
135
|
+
logger.info(f"Successful login for user: {username} from {client_ip}")
|
|
86
136
|
return {"access_token": token, "token_type": "bearer"}
|
|
137
|
+
|
|
138
|
+
# Record failed attempt
|
|
139
|
+
record_auth_failure(client_ip)
|
|
140
|
+
logger.warning(f"Failed login attempt for user: {username} from {client_ip}")
|
|
87
141
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
|
88
142
|
|
|
89
143
|
@app.get("/auth/me")
|
|
@@ -91,16 +145,28 @@ async def me(user: dict = Depends(verify_token)):
|
|
|
91
145
|
"""Get current user info"""
|
|
92
146
|
return user
|
|
93
147
|
|
|
148
|
+
@app.get("/csrf-token")
|
|
149
|
+
async def get_csrf_token(response: Response):
|
|
150
|
+
"""
|
|
151
|
+
Get CSRF Token for protected endpoints
|
|
152
|
+
|
|
153
|
+
Returns CSRF token that must be included in X-CSRF-Token header
|
|
154
|
+
for all POST/PUT/DELETE requests.
|
|
155
|
+
"""
|
|
156
|
+
return csrf_protection.set_token(response)
|
|
157
|
+
|
|
94
158
|
# ============================================================================
|
|
95
159
|
# SCANS
|
|
96
160
|
# ============================================================================
|
|
97
161
|
|
|
98
162
|
@app.post("/scans", response_model=ScanResponse)
|
|
99
163
|
async def create_new_scan(
|
|
164
|
+
request: Request,
|
|
100
165
|
scan: ScanCreate,
|
|
101
166
|
background_tasks: BackgroundTasks,
|
|
102
167
|
user: dict = Depends(verify_token),
|
|
103
|
-
db = Depends(get_db)
|
|
168
|
+
db = Depends(get_db),
|
|
169
|
+
_: bool = Depends(require_csrf)
|
|
104
170
|
):
|
|
105
171
|
"""Create a new pentest scan"""
|
|
106
172
|
db_scan = create_scan(
|