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 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
- # Configuration
13
- SECRET_KEY = "your-secret-key-here-change-in-production" # In production: env var
14
- ALGORITHM = "HS256"
15
- ACCESS_TOKEN_EXPIRE_MINUTES = 30
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
- # API Key Authentication (für CI/CD Integrationen)
108
- API_KEYS = {} # In production: in DB speichern
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
- # CORS
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=["*"], # Production: Einschränken!
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
- # Simplified - in production: verify against DB/LDAP
84
- if credentials.get("username") == "admin" and credentials.get("password") == "admin":
85
- token = create_access_token({"sub": "admin", "role": "admin"})
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(