pwndoc-mcp-server 1.0.2__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.
Potentially problematic release.
This version of pwndoc-mcp-server might be problematic. Click here for more details.
- pwndoc_mcp_server/__init__.py +57 -0
- pwndoc_mcp_server/cli.py +441 -0
- pwndoc_mcp_server/client.py +870 -0
- pwndoc_mcp_server/config.py +411 -0
- pwndoc_mcp_server/logging_config.py +329 -0
- pwndoc_mcp_server/server.py +950 -0
- pwndoc_mcp_server/version.py +26 -0
- pwndoc_mcp_server-1.0.2.dist-info/METADATA +110 -0
- pwndoc_mcp_server-1.0.2.dist-info/RECORD +11 -0
- pwndoc_mcp_server-1.0.2.dist-info/WHEEL +4 -0
- pwndoc_mcp_server-1.0.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PwnDoc API Client - HTTP client for PwnDoc REST API.
|
|
3
|
+
|
|
4
|
+
Handles authentication, rate limiting, retries, and all API endpoints.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from collections import deque
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from typing import Any, Dict, List, Optional, cast
|
|
12
|
+
|
|
13
|
+
import httpx # type: ignore[import-not-found]
|
|
14
|
+
|
|
15
|
+
from .config import Config
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RateLimiter:
|
|
21
|
+
"""Simple sliding window rate limiter."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, max_requests: int, period: int):
|
|
24
|
+
self.max_requests = max_requests
|
|
25
|
+
self.period = period
|
|
26
|
+
self.requests: deque = deque()
|
|
27
|
+
|
|
28
|
+
def acquire(self) -> bool:
|
|
29
|
+
"""Try to acquire a request slot."""
|
|
30
|
+
now = time.time()
|
|
31
|
+
|
|
32
|
+
# Remove old requests outside the window
|
|
33
|
+
while self.requests and self.requests[0] < now - self.period:
|
|
34
|
+
self.requests.popleft()
|
|
35
|
+
|
|
36
|
+
if len(self.requests) < self.max_requests:
|
|
37
|
+
self.requests.append(now)
|
|
38
|
+
return True
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
def wait_time(self) -> float:
|
|
42
|
+
"""Time to wait before next request is available."""
|
|
43
|
+
if len(self.requests) < self.max_requests:
|
|
44
|
+
return 0.0
|
|
45
|
+
return float(self.requests[0]) + self.period - time.time()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PwnDocError(Exception):
|
|
49
|
+
"""Base exception for PwnDoc API errors."""
|
|
50
|
+
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AuthenticationError(PwnDocError):
|
|
55
|
+
"""Authentication failed."""
|
|
56
|
+
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RateLimitError(PwnDocError):
|
|
61
|
+
"""Rate limit exceeded."""
|
|
62
|
+
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class NotFoundError(PwnDocError):
|
|
67
|
+
"""Resource not found."""
|
|
68
|
+
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class PwnDocClient:
|
|
73
|
+
"""
|
|
74
|
+
HTTP client for PwnDoc REST API.
|
|
75
|
+
|
|
76
|
+
Features:
|
|
77
|
+
- Automatic authentication and token refresh
|
|
78
|
+
- Rate limiting
|
|
79
|
+
- Automatic retries with exponential backoff
|
|
80
|
+
- Connection pooling
|
|
81
|
+
- Comprehensive error handling
|
|
82
|
+
|
|
83
|
+
Example:
|
|
84
|
+
>>> client = PwnDocClient(config)
|
|
85
|
+
>>> audits = client.list_audits()
|
|
86
|
+
>>> audit = client.get_audit("507f1f77bcf86cd799439011")
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
config: Optional[Config] = None,
|
|
92
|
+
url: Optional[str] = None,
|
|
93
|
+
username: Optional[str] = None,
|
|
94
|
+
password: Optional[str] = None,
|
|
95
|
+
token: Optional[str] = None,
|
|
96
|
+
verify_ssl: bool = True,
|
|
97
|
+
timeout: int = 30,
|
|
98
|
+
max_retries: int = 3,
|
|
99
|
+
retry_delay: float = 1.0,
|
|
100
|
+
rate_limit_requests: int = 100,
|
|
101
|
+
rate_limit_period: int = 60,
|
|
102
|
+
):
|
|
103
|
+
"""
|
|
104
|
+
Initialize PwnDoc client.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
config: Configuration object (if provided, other params are ignored)
|
|
108
|
+
url: PwnDoc server URL
|
|
109
|
+
username: Username for authentication
|
|
110
|
+
password: Password for authentication
|
|
111
|
+
token: Pre-authenticated JWT token
|
|
112
|
+
verify_ssl: Verify SSL certificates
|
|
113
|
+
timeout: Request timeout in seconds
|
|
114
|
+
max_retries: Maximum number of retries
|
|
115
|
+
retry_delay: Delay between retries
|
|
116
|
+
rate_limit_requests: Max requests per period
|
|
117
|
+
rate_limit_period: Rate limit period in seconds
|
|
118
|
+
"""
|
|
119
|
+
# If config provided, use it; otherwise create from parameters
|
|
120
|
+
if config is not None:
|
|
121
|
+
self.config = config
|
|
122
|
+
else:
|
|
123
|
+
self.config = Config(
|
|
124
|
+
url=url or "",
|
|
125
|
+
username=username or "",
|
|
126
|
+
password=password or "",
|
|
127
|
+
token=token or "",
|
|
128
|
+
verify_ssl=verify_ssl,
|
|
129
|
+
timeout=timeout,
|
|
130
|
+
max_retries=max_retries,
|
|
131
|
+
retry_delay=retry_delay,
|
|
132
|
+
rate_limit_requests=rate_limit_requests,
|
|
133
|
+
rate_limit_period=rate_limit_period,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
self.base_url = self.config.url.rstrip("/")
|
|
137
|
+
self._token: Optional[str] = self.config.token or None
|
|
138
|
+
self._token_expires: Optional[datetime] = None
|
|
139
|
+
self._refresh_token: Optional[str] = None
|
|
140
|
+
|
|
141
|
+
self.rate_limiter = RateLimiter(
|
|
142
|
+
self.config.rate_limit_requests, self.config.rate_limit_period
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Configure HTTP client
|
|
146
|
+
self._client = httpx.Client(
|
|
147
|
+
base_url=self.base_url,
|
|
148
|
+
timeout=self.config.timeout,
|
|
149
|
+
verify=self.config.verify_ssl,
|
|
150
|
+
follow_redirects=True,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
logger.debug(f"PwnDocClient initialized for {self.base_url}")
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def from_config(cls, config: Config) -> "PwnDocClient":
|
|
157
|
+
"""
|
|
158
|
+
Create client from config object.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
config: Configuration object
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
PwnDocClient instance
|
|
165
|
+
|
|
166
|
+
Example:
|
|
167
|
+
>>> config = Config(url="https://pwndoc.com", token="...")
|
|
168
|
+
>>> client = PwnDocClient.from_config(config)
|
|
169
|
+
"""
|
|
170
|
+
return cls(config=config)
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def url(self) -> str:
|
|
174
|
+
"""Get the base URL."""
|
|
175
|
+
return self.base_url
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def token(self) -> Optional[str]:
|
|
179
|
+
"""Get the current token."""
|
|
180
|
+
return self._token
|
|
181
|
+
|
|
182
|
+
def __enter__(self):
|
|
183
|
+
return self
|
|
184
|
+
|
|
185
|
+
def __exit__(self, *args):
|
|
186
|
+
self._client.close()
|
|
187
|
+
|
|
188
|
+
async def __aenter__(self):
|
|
189
|
+
return self
|
|
190
|
+
|
|
191
|
+
async def __aexit__(self, *args):
|
|
192
|
+
await self.close()
|
|
193
|
+
|
|
194
|
+
async def close(self):
|
|
195
|
+
"""Close the HTTP client (async)."""
|
|
196
|
+
self._client.close()
|
|
197
|
+
|
|
198
|
+
async def _ensure_token(self):
|
|
199
|
+
"""Ensure we have a valid authentication token (async wrapper)."""
|
|
200
|
+
if not self.is_authenticated:
|
|
201
|
+
self.authenticate()
|
|
202
|
+
|
|
203
|
+
async def test_connection(self) -> Dict[str, Any]:
|
|
204
|
+
"""
|
|
205
|
+
Test the connection to PwnDoc server.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Dict with status and connection info
|
|
209
|
+
|
|
210
|
+
Example:
|
|
211
|
+
>>> result = await client.test_connection()
|
|
212
|
+
>>> if result["status"] == "ok":
|
|
213
|
+
... print(f"Connected as {result['user']}")
|
|
214
|
+
"""
|
|
215
|
+
try:
|
|
216
|
+
# Try to ensure token (async for test compatibility)
|
|
217
|
+
await self._ensure_token()
|
|
218
|
+
|
|
219
|
+
# Get current user to verify connection
|
|
220
|
+
user_data = self.get_current_user()
|
|
221
|
+
|
|
222
|
+
# Handle both sync and async mocked returns
|
|
223
|
+
if hasattr(user_data, "__await__"):
|
|
224
|
+
user_data = await user_data
|
|
225
|
+
|
|
226
|
+
# Extract username from response (handle both direct and nested format)
|
|
227
|
+
if isinstance(user_data, dict):
|
|
228
|
+
if "datas" in user_data:
|
|
229
|
+
username = user_data.get("datas", {}).get("username", "unknown")
|
|
230
|
+
else:
|
|
231
|
+
username = user_data.get("username", "unknown")
|
|
232
|
+
else:
|
|
233
|
+
username = "unknown"
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
"status": "ok",
|
|
237
|
+
"user": username,
|
|
238
|
+
"url": self.base_url,
|
|
239
|
+
}
|
|
240
|
+
except Exception as e:
|
|
241
|
+
return {
|
|
242
|
+
"status": "error",
|
|
243
|
+
"error": str(e),
|
|
244
|
+
"url": self.base_url,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def is_authenticated(self) -> bool:
|
|
249
|
+
"""Check if client has valid authentication."""
|
|
250
|
+
if not self._token:
|
|
251
|
+
return False
|
|
252
|
+
if self._token_expires and datetime.now() >= self._token_expires:
|
|
253
|
+
return False
|
|
254
|
+
return True
|
|
255
|
+
|
|
256
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
257
|
+
"""Get request headers with authentication."""
|
|
258
|
+
headers = {
|
|
259
|
+
"Content-Type": "application/json",
|
|
260
|
+
"Accept": "application/json",
|
|
261
|
+
}
|
|
262
|
+
if self._token:
|
|
263
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
264
|
+
return headers
|
|
265
|
+
|
|
266
|
+
def authenticate(self) -> bool:
|
|
267
|
+
"""
|
|
268
|
+
Authenticate with PwnDoc and obtain JWT token.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
bool: True if authentication succeeded
|
|
272
|
+
|
|
273
|
+
Raises:
|
|
274
|
+
AuthenticationError: If authentication fails
|
|
275
|
+
"""
|
|
276
|
+
if self.config.token:
|
|
277
|
+
self._token = self.config.token
|
|
278
|
+
logger.info("Using pre-configured token")
|
|
279
|
+
return True
|
|
280
|
+
|
|
281
|
+
if not self.config.username or not self.config.password:
|
|
282
|
+
raise AuthenticationError("No credentials configured")
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
response = self._client.post(
|
|
286
|
+
"/api/users/login",
|
|
287
|
+
json={
|
|
288
|
+
"username": self.config.username,
|
|
289
|
+
"password": self.config.password,
|
|
290
|
+
},
|
|
291
|
+
headers={"Content-Type": "application/json"},
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if response.status_code == 200:
|
|
295
|
+
data = response.json()
|
|
296
|
+
self._token = data.get("datas", {}).get("token")
|
|
297
|
+
|
|
298
|
+
# Extract refresh token from cookies if present
|
|
299
|
+
if "refreshToken" in response.cookies:
|
|
300
|
+
self._refresh_token = response.cookies["refreshToken"]
|
|
301
|
+
|
|
302
|
+
# Set token expiry (default 1 hour)
|
|
303
|
+
self._token_expires = datetime.now() + timedelta(hours=1)
|
|
304
|
+
|
|
305
|
+
logger.info("Authentication successful")
|
|
306
|
+
return True
|
|
307
|
+
else:
|
|
308
|
+
raise AuthenticationError(
|
|
309
|
+
f"Authentication failed: {response.status_code} - {response.text}"
|
|
310
|
+
)
|
|
311
|
+
except httpx.RequestError as e:
|
|
312
|
+
raise AuthenticationError(f"Connection error: {e}")
|
|
313
|
+
|
|
314
|
+
def refresh_authentication(self) -> bool:
|
|
315
|
+
"""Refresh the authentication token."""
|
|
316
|
+
if self._refresh_token:
|
|
317
|
+
try:
|
|
318
|
+
response = self._client.get(
|
|
319
|
+
"/api/users/refreshtoken",
|
|
320
|
+
cookies={"refreshToken": self._refresh_token},
|
|
321
|
+
)
|
|
322
|
+
if response.status_code == 200:
|
|
323
|
+
data = response.json()
|
|
324
|
+
self._token = data.get("datas", {}).get("token")
|
|
325
|
+
self._token_expires = datetime.now() + timedelta(hours=1)
|
|
326
|
+
logger.debug("Token refreshed")
|
|
327
|
+
return True
|
|
328
|
+
except Exception as e:
|
|
329
|
+
logger.warning(f"Token refresh failed: {e}")
|
|
330
|
+
|
|
331
|
+
# Fall back to full authentication
|
|
332
|
+
return self.authenticate()
|
|
333
|
+
|
|
334
|
+
def _ensure_authenticated(self):
|
|
335
|
+
"""Ensure we have valid authentication."""
|
|
336
|
+
if not self.is_authenticated:
|
|
337
|
+
if self._refresh_token:
|
|
338
|
+
self.refresh_authentication()
|
|
339
|
+
else:
|
|
340
|
+
self.authenticate()
|
|
341
|
+
|
|
342
|
+
def _wait_for_rate_limit(self):
|
|
343
|
+
"""Wait if rate limited."""
|
|
344
|
+
while not self.rate_limiter.acquire():
|
|
345
|
+
wait_time = self.rate_limiter.wait_time()
|
|
346
|
+
logger.debug(f"Rate limited, waiting {wait_time:.2f}s")
|
|
347
|
+
time.sleep(wait_time)
|
|
348
|
+
|
|
349
|
+
def _request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
|
|
350
|
+
"""
|
|
351
|
+
Make an API request with retries and error handling.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
method: HTTP method (GET, POST, PUT, DELETE)
|
|
355
|
+
endpoint: API endpoint path
|
|
356
|
+
**kwargs: Additional request arguments
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
Parsed JSON response
|
|
360
|
+
|
|
361
|
+
Raises:
|
|
362
|
+
PwnDocError: On API errors
|
|
363
|
+
"""
|
|
364
|
+
self._ensure_authenticated()
|
|
365
|
+
self._wait_for_rate_limit()
|
|
366
|
+
|
|
367
|
+
url = endpoint if endpoint.startswith("/") else f"/{endpoint}"
|
|
368
|
+
headers = self._get_headers()
|
|
369
|
+
headers.update(kwargs.pop("headers", {}))
|
|
370
|
+
|
|
371
|
+
last_error = None
|
|
372
|
+
for attempt in range(self.config.max_retries):
|
|
373
|
+
try:
|
|
374
|
+
response = self._client.request(method, url, headers=headers, **kwargs)
|
|
375
|
+
|
|
376
|
+
# Handle response
|
|
377
|
+
if response.status_code == 200:
|
|
378
|
+
try:
|
|
379
|
+
return cast(Dict[str, Any], response.json())
|
|
380
|
+
except Exception:
|
|
381
|
+
return {"raw": response.text}
|
|
382
|
+
elif response.status_code == 401:
|
|
383
|
+
# Token expired, try to refresh
|
|
384
|
+
self.refresh_authentication()
|
|
385
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
386
|
+
continue
|
|
387
|
+
elif response.status_code == 404:
|
|
388
|
+
raise NotFoundError(f"Resource not found: {endpoint}")
|
|
389
|
+
elif response.status_code == 429:
|
|
390
|
+
retry_after = int(response.headers.get("Retry-After", 60))
|
|
391
|
+
raise RateLimitError(f"Rate limited, retry after {retry_after}s")
|
|
392
|
+
else:
|
|
393
|
+
raise PwnDocError(f"API error: {response.status_code} - {response.text}")
|
|
394
|
+
|
|
395
|
+
except httpx.RequestError as e:
|
|
396
|
+
last_error = e
|
|
397
|
+
logger.warning(f"Request failed (attempt {attempt + 1}): {e}")
|
|
398
|
+
if attempt < self.config.max_retries - 1:
|
|
399
|
+
time.sleep(self.config.retry_delay * (2**attempt))
|
|
400
|
+
|
|
401
|
+
raise PwnDocError(f"Request failed after {self.config.max_retries} retries: {last_error}")
|
|
402
|
+
|
|
403
|
+
def _get(self, endpoint: str, **kwargs) -> Dict[str, Any]:
|
|
404
|
+
"""Make GET request."""
|
|
405
|
+
return self._request("GET", endpoint, **kwargs)
|
|
406
|
+
|
|
407
|
+
def _post(self, endpoint: str, **kwargs) -> Dict[str, Any]:
|
|
408
|
+
"""Make POST request."""
|
|
409
|
+
return self._request("POST", endpoint, **kwargs)
|
|
410
|
+
|
|
411
|
+
def _put(self, endpoint: str, **kwargs) -> Dict[str, Any]:
|
|
412
|
+
"""Make PUT request."""
|
|
413
|
+
return self._request("PUT", endpoint, **kwargs)
|
|
414
|
+
|
|
415
|
+
def _delete(self, endpoint: str, **kwargs) -> Dict[str, Any]:
|
|
416
|
+
"""Make DELETE request."""
|
|
417
|
+
return self._request("DELETE", endpoint, **kwargs)
|
|
418
|
+
|
|
419
|
+
# =========================================================================
|
|
420
|
+
# AUDIT ENDPOINTS
|
|
421
|
+
# =========================================================================
|
|
422
|
+
|
|
423
|
+
def list_audits(self, finding_title: Optional[str] = None) -> List[Dict]:
|
|
424
|
+
"""List all audits, optionally filtered by finding title."""
|
|
425
|
+
response = self._get("/api/audits")
|
|
426
|
+
audits = response.get("datas", [])
|
|
427
|
+
|
|
428
|
+
if finding_title:
|
|
429
|
+
filtered = []
|
|
430
|
+
for audit in audits:
|
|
431
|
+
for finding in audit.get("findings", []):
|
|
432
|
+
if finding_title.lower() in finding.get("title", "").lower():
|
|
433
|
+
filtered.append(audit)
|
|
434
|
+
break
|
|
435
|
+
return filtered
|
|
436
|
+
return audits
|
|
437
|
+
|
|
438
|
+
def get_audit(self, audit_id: str) -> Dict:
|
|
439
|
+
"""Get detailed audit information."""
|
|
440
|
+
response = self._get(f"/api/audits/{audit_id}")
|
|
441
|
+
return response.get("datas", {})
|
|
442
|
+
|
|
443
|
+
def get_audit_general(self, audit_id: str) -> Dict:
|
|
444
|
+
"""Get audit general information."""
|
|
445
|
+
response = self._get(f"/api/audits/{audit_id}/general")
|
|
446
|
+
return response.get("datas", {})
|
|
447
|
+
|
|
448
|
+
def create_audit(self, name: str, language: str, audit_type: str, **kwargs) -> Dict:
|
|
449
|
+
"""Create a new audit."""
|
|
450
|
+
data = {"name": name, "language": language, "auditType": audit_type, **kwargs}
|
|
451
|
+
response = self._post("/api/audits", json=data)
|
|
452
|
+
return response.get("datas", {})
|
|
453
|
+
|
|
454
|
+
def update_audit_general(self, audit_id: str, **kwargs) -> Dict:
|
|
455
|
+
"""Update audit general information."""
|
|
456
|
+
response = self._put(f"/api/audits/{audit_id}/general", json=kwargs)
|
|
457
|
+
return response.get("datas", {})
|
|
458
|
+
|
|
459
|
+
def delete_audit(self, audit_id: str) -> bool:
|
|
460
|
+
"""Delete an audit."""
|
|
461
|
+
self._delete(f"/api/audits/{audit_id}")
|
|
462
|
+
return True
|
|
463
|
+
|
|
464
|
+
def generate_report(self, audit_id: str) -> bytes:
|
|
465
|
+
"""Generate and download audit report."""
|
|
466
|
+
self._ensure_authenticated()
|
|
467
|
+
response = self._client.get(
|
|
468
|
+
f"/api/audits/{audit_id}/generate",
|
|
469
|
+
headers=self._get_headers(),
|
|
470
|
+
)
|
|
471
|
+
if response.status_code == 200:
|
|
472
|
+
return response.content
|
|
473
|
+
raise PwnDocError(f"Report generation failed: {response.status_code}")
|
|
474
|
+
|
|
475
|
+
def get_audit_network(self, audit_id: str) -> Dict:
|
|
476
|
+
"""Get audit network information."""
|
|
477
|
+
response = self._get(f"/api/audits/{audit_id}/network")
|
|
478
|
+
return response.get("datas", {})
|
|
479
|
+
|
|
480
|
+
def update_audit_network(self, audit_id: str, network_data: Dict) -> Dict:
|
|
481
|
+
"""Update audit network information."""
|
|
482
|
+
response = self._put(f"/api/audits/{audit_id}/network", json=network_data)
|
|
483
|
+
return response.get("datas", {})
|
|
484
|
+
|
|
485
|
+
def toggle_audit_approval(self, audit_id: str) -> Dict:
|
|
486
|
+
"""Toggle audit approval status."""
|
|
487
|
+
response = self._put(f"/api/audits/{audit_id}/toggleApproval")
|
|
488
|
+
return response.get("datas", {})
|
|
489
|
+
|
|
490
|
+
def update_review_status(self, audit_id: str, state: bool) -> Dict:
|
|
491
|
+
"""Update audit review ready status."""
|
|
492
|
+
response = self._put(f"/api/audits/{audit_id}/updateReadyForReview", json={"state": state})
|
|
493
|
+
return response.get("datas", {})
|
|
494
|
+
|
|
495
|
+
# =========================================================================
|
|
496
|
+
# FINDING ENDPOINTS
|
|
497
|
+
# =========================================================================
|
|
498
|
+
|
|
499
|
+
def get_findings(self, audit_id: str) -> List[Dict]:
|
|
500
|
+
"""Get all findings for an audit."""
|
|
501
|
+
response = self._get(f"/api/audits/{audit_id}/findings")
|
|
502
|
+
return response.get("datas", [])
|
|
503
|
+
|
|
504
|
+
def get_finding(self, audit_id: str, finding_id: str) -> Dict:
|
|
505
|
+
"""Get specific finding details."""
|
|
506
|
+
response = self._get(f"/api/audits/{audit_id}/findings/{finding_id}")
|
|
507
|
+
return response.get("datas", {})
|
|
508
|
+
|
|
509
|
+
def create_finding(self, audit_id: str, **kwargs) -> Dict:
|
|
510
|
+
"""Create a new finding."""
|
|
511
|
+
response = self._post(f"/api/audits/{audit_id}/findings", json=kwargs)
|
|
512
|
+
return response.get("datas", {})
|
|
513
|
+
|
|
514
|
+
def update_finding(self, audit_id: str, finding_id: str, **kwargs) -> Dict:
|
|
515
|
+
"""Update an existing finding."""
|
|
516
|
+
response = self._put(f"/api/audits/{audit_id}/findings/{finding_id}", json=kwargs)
|
|
517
|
+
return response.get("datas", {})
|
|
518
|
+
|
|
519
|
+
def delete_finding(self, audit_id: str, finding_id: str) -> bool:
|
|
520
|
+
"""Delete a finding."""
|
|
521
|
+
self._delete(f"/api/audits/{audit_id}/findings/{finding_id}")
|
|
522
|
+
return True
|
|
523
|
+
|
|
524
|
+
def sort_findings(self, audit_id: str, finding_order: List[str]) -> Dict:
|
|
525
|
+
"""Reorder findings in an audit."""
|
|
526
|
+
response = self._put(
|
|
527
|
+
f"/api/audits/{audit_id}/sortFindings", json={"findings": finding_order}
|
|
528
|
+
)
|
|
529
|
+
return response.get("datas", {})
|
|
530
|
+
|
|
531
|
+
def move_finding(self, audit_id: str, finding_id: str, destination_audit_id: str) -> Dict:
|
|
532
|
+
"""Move finding to another audit."""
|
|
533
|
+
response = self._post(
|
|
534
|
+
f"/api/audits/{audit_id}/findings/{finding_id}/move/{destination_audit_id}"
|
|
535
|
+
)
|
|
536
|
+
return response.get("datas", {})
|
|
537
|
+
|
|
538
|
+
# =========================================================================
|
|
539
|
+
# CLIENT & COMPANY ENDPOINTS
|
|
540
|
+
# =========================================================================
|
|
541
|
+
|
|
542
|
+
def list_clients(self) -> List[Dict]:
|
|
543
|
+
"""List all clients."""
|
|
544
|
+
response = self._get("/api/clients")
|
|
545
|
+
return response.get("datas", [])
|
|
546
|
+
|
|
547
|
+
def create_client(self, **kwargs) -> Dict:
|
|
548
|
+
"""Create a new client."""
|
|
549
|
+
response = self._post("/api/clients", json=kwargs)
|
|
550
|
+
return response.get("datas", {})
|
|
551
|
+
|
|
552
|
+
def update_client(self, client_id: str, **kwargs) -> Dict:
|
|
553
|
+
"""Update a client."""
|
|
554
|
+
response = self._put(f"/api/clients/{client_id}", json=kwargs)
|
|
555
|
+
return response.get("datas", {})
|
|
556
|
+
|
|
557
|
+
def delete_client(self, client_id: str) -> bool:
|
|
558
|
+
"""Delete a client."""
|
|
559
|
+
self._delete(f"/api/clients/{client_id}")
|
|
560
|
+
return True
|
|
561
|
+
|
|
562
|
+
def list_companies(self) -> List[Dict]:
|
|
563
|
+
"""List all companies."""
|
|
564
|
+
response = self._get("/api/companies")
|
|
565
|
+
return response.get("datas", [])
|
|
566
|
+
|
|
567
|
+
def create_company(self, **kwargs) -> Dict:
|
|
568
|
+
"""Create a new company."""
|
|
569
|
+
response = self._post("/api/companies", json=kwargs)
|
|
570
|
+
return response.get("datas", {})
|
|
571
|
+
|
|
572
|
+
def update_company(self, company_id: str, **kwargs) -> Dict:
|
|
573
|
+
"""Update a company."""
|
|
574
|
+
response = self._put(f"/api/companies/{company_id}", json=kwargs)
|
|
575
|
+
return response.get("datas", {})
|
|
576
|
+
|
|
577
|
+
def delete_company(self, company_id: str) -> bool:
|
|
578
|
+
"""Delete a company."""
|
|
579
|
+
self._delete(f"/api/companies/{company_id}")
|
|
580
|
+
return True
|
|
581
|
+
|
|
582
|
+
# =========================================================================
|
|
583
|
+
# VULNERABILITY TEMPLATE ENDPOINTS
|
|
584
|
+
# =========================================================================
|
|
585
|
+
|
|
586
|
+
def list_vulnerabilities(self) -> List[Dict]:
|
|
587
|
+
"""List all vulnerability templates."""
|
|
588
|
+
response = self._get("/api/vulnerabilities")
|
|
589
|
+
return response.get("datas", [])
|
|
590
|
+
|
|
591
|
+
def get_vulnerabilities_by_locale(self, locale: str = "en") -> List[Dict]:
|
|
592
|
+
"""Get vulnerability templates for a locale."""
|
|
593
|
+
response = self._get(f"/api/vulnerabilities/{locale}")
|
|
594
|
+
return response.get("datas", [])
|
|
595
|
+
|
|
596
|
+
def create_vulnerability(self, **kwargs) -> Dict:
|
|
597
|
+
"""Create a vulnerability template."""
|
|
598
|
+
response = self._post("/api/vulnerabilities", json=kwargs)
|
|
599
|
+
return response.get("datas", {})
|
|
600
|
+
|
|
601
|
+
def update_vulnerability(self, vuln_id: str, **kwargs) -> Dict:
|
|
602
|
+
"""Update a vulnerability template."""
|
|
603
|
+
response = self._put(f"/api/vulnerabilities/{vuln_id}", json=kwargs)
|
|
604
|
+
return response.get("datas", {})
|
|
605
|
+
|
|
606
|
+
def delete_vulnerability(self, vuln_id: str) -> bool:
|
|
607
|
+
"""Delete a vulnerability template."""
|
|
608
|
+
self._delete(f"/api/vulnerabilities/{vuln_id}")
|
|
609
|
+
return True
|
|
610
|
+
|
|
611
|
+
def bulk_delete_vulnerabilities(self, vuln_ids: List[str]) -> bool:
|
|
612
|
+
"""Bulk delete vulnerability templates."""
|
|
613
|
+
self._delete("/api/vulnerabilities", json={"vulnIds": vuln_ids})
|
|
614
|
+
return True
|
|
615
|
+
|
|
616
|
+
def export_vulnerabilities(self) -> Dict:
|
|
617
|
+
"""Export all vulnerability templates."""
|
|
618
|
+
response = self._get("/api/vulnerabilities/export")
|
|
619
|
+
return response.get("datas", {})
|
|
620
|
+
|
|
621
|
+
def create_vulnerability_from_finding(self, **kwargs) -> Dict:
|
|
622
|
+
"""Create vulnerability template from finding."""
|
|
623
|
+
response = self._post("/api/vulnerabilities/from-finding", json=kwargs)
|
|
624
|
+
return response.get("datas", {})
|
|
625
|
+
|
|
626
|
+
# =========================================================================
|
|
627
|
+
# USER ENDPOINTS
|
|
628
|
+
# =========================================================================
|
|
629
|
+
|
|
630
|
+
def list_users(self) -> List[Dict]:
|
|
631
|
+
"""List all users (admin only)."""
|
|
632
|
+
response = self._get("/api/users")
|
|
633
|
+
return response.get("datas", [])
|
|
634
|
+
|
|
635
|
+
def get_user(self, username: str) -> Dict:
|
|
636
|
+
"""Get user by username."""
|
|
637
|
+
response = self._get(f"/api/users/{username}")
|
|
638
|
+
return response.get("datas", {})
|
|
639
|
+
|
|
640
|
+
def get_current_user(self) -> Dict:
|
|
641
|
+
"""Get current authenticated user."""
|
|
642
|
+
response = self._get("/api/users/me")
|
|
643
|
+
return response.get("datas", {})
|
|
644
|
+
|
|
645
|
+
def create_user(self, **kwargs) -> Dict:
|
|
646
|
+
"""Create a new user (admin only)."""
|
|
647
|
+
response = self._post("/api/users", json=kwargs)
|
|
648
|
+
return response.get("datas", {})
|
|
649
|
+
|
|
650
|
+
def update_user(self, user_id: str, **kwargs) -> Dict:
|
|
651
|
+
"""Update a user (admin only)."""
|
|
652
|
+
response = self._put(f"/api/users/{user_id}", json=kwargs)
|
|
653
|
+
return response.get("datas", {})
|
|
654
|
+
|
|
655
|
+
def update_current_user(self, **kwargs) -> Dict:
|
|
656
|
+
"""Update current user profile."""
|
|
657
|
+
response = self._put("/api/users/me", json=kwargs)
|
|
658
|
+
return response.get("datas", {})
|
|
659
|
+
|
|
660
|
+
def list_reviewers(self) -> List[Dict]:
|
|
661
|
+
"""List all reviewers."""
|
|
662
|
+
response = self._get("/api/users/reviewers")
|
|
663
|
+
return response.get("datas", [])
|
|
664
|
+
|
|
665
|
+
# =========================================================================
|
|
666
|
+
# TEMPLATE & SETTINGS ENDPOINTS
|
|
667
|
+
# =========================================================================
|
|
668
|
+
|
|
669
|
+
def list_templates(self) -> List[Dict]:
|
|
670
|
+
"""List report templates."""
|
|
671
|
+
response = self._get("/api/templates")
|
|
672
|
+
return response.get("datas", [])
|
|
673
|
+
|
|
674
|
+
def create_template(self, name: str, ext: str, file_content: str) -> Dict:
|
|
675
|
+
"""Create/upload a report template."""
|
|
676
|
+
response = self._post(
|
|
677
|
+
"/api/templates", json={"name": name, "ext": ext, "file": file_content}
|
|
678
|
+
)
|
|
679
|
+
return response.get("datas", {})
|
|
680
|
+
|
|
681
|
+
def update_template(self, template_id: str, **kwargs) -> Dict:
|
|
682
|
+
"""Update a template."""
|
|
683
|
+
response = self._put(f"/api/templates/{template_id}", json=kwargs)
|
|
684
|
+
return response.get("datas", {})
|
|
685
|
+
|
|
686
|
+
def delete_template(self, template_id: str) -> bool:
|
|
687
|
+
"""Delete a template."""
|
|
688
|
+
self._delete(f"/api/templates/{template_id}")
|
|
689
|
+
return True
|
|
690
|
+
|
|
691
|
+
def download_template(self, template_id: str) -> bytes:
|
|
692
|
+
"""Download a template file."""
|
|
693
|
+
self._ensure_authenticated()
|
|
694
|
+
response = self._client.get(
|
|
695
|
+
f"/api/templates/download/{template_id}",
|
|
696
|
+
headers=self._get_headers(),
|
|
697
|
+
)
|
|
698
|
+
return response.content
|
|
699
|
+
|
|
700
|
+
def get_settings(self) -> Dict:
|
|
701
|
+
"""Get system settings."""
|
|
702
|
+
response = self._get("/api/settings")
|
|
703
|
+
return response.get("datas", {})
|
|
704
|
+
|
|
705
|
+
def get_public_settings(self) -> Dict:
|
|
706
|
+
"""Get public settings."""
|
|
707
|
+
response = self._get("/api/settings/public")
|
|
708
|
+
return response.get("datas", {})
|
|
709
|
+
|
|
710
|
+
def update_settings(self, settings: Dict) -> Dict:
|
|
711
|
+
"""Update system settings."""
|
|
712
|
+
response = self._put("/api/settings", json=settings)
|
|
713
|
+
return response.get("datas", {})
|
|
714
|
+
|
|
715
|
+
# =========================================================================
|
|
716
|
+
# DATA TYPE ENDPOINTS
|
|
717
|
+
# =========================================================================
|
|
718
|
+
|
|
719
|
+
def list_languages(self) -> List[Dict]:
|
|
720
|
+
"""List all languages."""
|
|
721
|
+
response = self._get("/api/data/languages")
|
|
722
|
+
return response.get("datas", [])
|
|
723
|
+
|
|
724
|
+
def list_audit_types(self) -> List[Dict]:
|
|
725
|
+
"""List all audit types."""
|
|
726
|
+
response = self._get("/api/data/audit-types")
|
|
727
|
+
return response.get("datas", [])
|
|
728
|
+
|
|
729
|
+
def list_vulnerability_types(self) -> List[Dict]:
|
|
730
|
+
"""List all vulnerability types."""
|
|
731
|
+
response = self._get("/api/data/vulnerability-types")
|
|
732
|
+
return response.get("datas", [])
|
|
733
|
+
|
|
734
|
+
def list_vulnerability_categories(self) -> List[Dict]:
|
|
735
|
+
"""List all vulnerability categories."""
|
|
736
|
+
response = self._get("/api/data/vulnerability-categories")
|
|
737
|
+
return response.get("datas", [])
|
|
738
|
+
|
|
739
|
+
def list_sections(self) -> List[Dict]:
|
|
740
|
+
"""List all section definitions."""
|
|
741
|
+
response = self._get("/api/data/sections")
|
|
742
|
+
return response.get("datas", [])
|
|
743
|
+
|
|
744
|
+
def list_custom_fields(self) -> List[Dict]:
|
|
745
|
+
"""List all custom field definitions."""
|
|
746
|
+
response = self._get("/api/data/custom-fields")
|
|
747
|
+
return response.get("datas", [])
|
|
748
|
+
|
|
749
|
+
def list_roles(self) -> List[Dict]:
|
|
750
|
+
"""List all user roles."""
|
|
751
|
+
response = self._get("/api/data/roles")
|
|
752
|
+
return response.get("datas", [])
|
|
753
|
+
|
|
754
|
+
# =========================================================================
|
|
755
|
+
# IMAGE ENDPOINTS
|
|
756
|
+
# =========================================================================
|
|
757
|
+
|
|
758
|
+
def get_image(self, image_id: str) -> Dict:
|
|
759
|
+
"""Get image metadata."""
|
|
760
|
+
response = self._get(f"/api/images/{image_id}")
|
|
761
|
+
return response.get("datas", {})
|
|
762
|
+
|
|
763
|
+
def download_image(self, image_id: str) -> bytes:
|
|
764
|
+
"""Download an image file."""
|
|
765
|
+
self._ensure_authenticated()
|
|
766
|
+
response = self._client.get(
|
|
767
|
+
f"/api/images/download/{image_id}",
|
|
768
|
+
headers=self._get_headers(),
|
|
769
|
+
)
|
|
770
|
+
return response.content
|
|
771
|
+
|
|
772
|
+
def upload_image(self, audit_id: str, name: str, value: str) -> Dict:
|
|
773
|
+
"""Upload an image."""
|
|
774
|
+
response = self._post(
|
|
775
|
+
"/api/images", json={"auditId": audit_id, "name": name, "value": value}
|
|
776
|
+
)
|
|
777
|
+
return response.get("datas", {})
|
|
778
|
+
|
|
779
|
+
def delete_image(self, image_id: str) -> bool:
|
|
780
|
+
"""Delete an image."""
|
|
781
|
+
self._delete(f"/api/images/{image_id}")
|
|
782
|
+
return True
|
|
783
|
+
|
|
784
|
+
# =========================================================================
|
|
785
|
+
# STATISTICS
|
|
786
|
+
# =========================================================================
|
|
787
|
+
|
|
788
|
+
def get_statistics(self) -> Dict:
|
|
789
|
+
"""Get comprehensive statistics."""
|
|
790
|
+
# Aggregate statistics from multiple endpoints
|
|
791
|
+
stats = {
|
|
792
|
+
"audits": len(self.list_audits()),
|
|
793
|
+
"clients": len(self.list_clients()),
|
|
794
|
+
"companies": len(self.list_companies()),
|
|
795
|
+
"vulnerability_templates": len(self.list_vulnerabilities()),
|
|
796
|
+
"users": len(self.list_users()),
|
|
797
|
+
}
|
|
798
|
+
return stats
|
|
799
|
+
|
|
800
|
+
def search_findings(
|
|
801
|
+
self,
|
|
802
|
+
title: Optional[str] = None,
|
|
803
|
+
category: Optional[str] = None,
|
|
804
|
+
severity: Optional[str] = None,
|
|
805
|
+
status: Optional[str] = None,
|
|
806
|
+
) -> List[Dict]:
|
|
807
|
+
"""Search findings across all audits."""
|
|
808
|
+
results = []
|
|
809
|
+
audits = self.list_audits()
|
|
810
|
+
|
|
811
|
+
for audit in audits:
|
|
812
|
+
findings = self.get_findings(audit["_id"])
|
|
813
|
+
for finding in findings:
|
|
814
|
+
match = True
|
|
815
|
+
|
|
816
|
+
if title and title.lower() not in finding.get("title", "").lower():
|
|
817
|
+
match = False
|
|
818
|
+
if category and category.lower() != finding.get("category", "").lower():
|
|
819
|
+
match = False
|
|
820
|
+
if severity:
|
|
821
|
+
# Map CVSS to severity
|
|
822
|
+
cvss = finding.get("cvssv3", "")
|
|
823
|
+
if severity.lower() == "critical" and not (
|
|
824
|
+
cvss and float(cvss.split("/")[0]) >= 9.0
|
|
825
|
+
):
|
|
826
|
+
match = False
|
|
827
|
+
elif severity.lower() == "high" and not (
|
|
828
|
+
cvss and 7.0 <= float(cvss.split("/")[0]) < 9.0
|
|
829
|
+
):
|
|
830
|
+
match = False
|
|
831
|
+
|
|
832
|
+
if match:
|
|
833
|
+
finding["_audit_id"] = audit["_id"]
|
|
834
|
+
finding["_audit_name"] = audit.get("name", "")
|
|
835
|
+
results.append(finding)
|
|
836
|
+
|
|
837
|
+
return results
|
|
838
|
+
|
|
839
|
+
def get_all_findings_with_context(
|
|
840
|
+
self, include_failed: bool = False, exclude_categories: Optional[List[str]] = None
|
|
841
|
+
) -> List[Dict]:
|
|
842
|
+
"""Get all findings with full audit context."""
|
|
843
|
+
exclude_categories = exclude_categories or []
|
|
844
|
+
if not include_failed:
|
|
845
|
+
exclude_categories.append("Failed")
|
|
846
|
+
|
|
847
|
+
results = []
|
|
848
|
+
audits = self.list_audits()
|
|
849
|
+
|
|
850
|
+
for audit in audits:
|
|
851
|
+
audit_detail = self.get_audit(audit["_id"])
|
|
852
|
+
findings = self.get_findings(audit["_id"])
|
|
853
|
+
|
|
854
|
+
for finding in findings:
|
|
855
|
+
if finding.get("category") in exclude_categories:
|
|
856
|
+
continue
|
|
857
|
+
|
|
858
|
+
# Add audit context
|
|
859
|
+
finding["audit"] = {
|
|
860
|
+
"_id": audit["_id"],
|
|
861
|
+
"name": audit.get("name"),
|
|
862
|
+
"company": audit_detail.get("company", {}).get("name"),
|
|
863
|
+
"client": audit_detail.get("client", {}).get("email"),
|
|
864
|
+
"date_start": audit_detail.get("date_start"),
|
|
865
|
+
"date_end": audit_detail.get("date_end"),
|
|
866
|
+
"scope": audit_detail.get("scope", []),
|
|
867
|
+
}
|
|
868
|
+
results.append(finding)
|
|
869
|
+
|
|
870
|
+
return results
|