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.

@@ -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