zen-ai-pentest 2.0.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.
Files changed (75) hide show
  1. agents/__init__.py +28 -0
  2. agents/agent_base.py +239 -0
  3. agents/agent_orchestrator.py +346 -0
  4. agents/analysis_agent.py +225 -0
  5. agents/cli.py +258 -0
  6. agents/exploit_agent.py +224 -0
  7. agents/integration.py +211 -0
  8. agents/post_scan_agent.py +937 -0
  9. agents/react_agent.py +384 -0
  10. agents/react_agent_enhanced.py +616 -0
  11. agents/react_agent_vm.py +298 -0
  12. agents/research_agent.py +176 -0
  13. api/__init__.py +11 -0
  14. api/auth.py +123 -0
  15. api/main.py +1027 -0
  16. api/schemas.py +357 -0
  17. api/websocket.py +97 -0
  18. autonomous/__init__.py +122 -0
  19. autonomous/agent.py +253 -0
  20. autonomous/agent_loop.py +1370 -0
  21. autonomous/exploit_validator.py +1537 -0
  22. autonomous/memory.py +448 -0
  23. autonomous/react.py +339 -0
  24. autonomous/tool_executor.py +488 -0
  25. backends/__init__.py +16 -0
  26. backends/chatgpt_direct.py +133 -0
  27. backends/claude_direct.py +130 -0
  28. backends/duckduckgo.py +138 -0
  29. backends/openrouter.py +120 -0
  30. benchmarks/__init__.py +149 -0
  31. benchmarks/benchmark_engine.py +904 -0
  32. benchmarks/ci_benchmark.py +785 -0
  33. benchmarks/comparison.py +729 -0
  34. benchmarks/metrics.py +553 -0
  35. benchmarks/run_benchmarks.py +809 -0
  36. ci_cd/__init__.py +2 -0
  37. core/__init__.py +17 -0
  38. core/async_pool.py +282 -0
  39. core/asyncio_fix.py +222 -0
  40. core/cache.py +472 -0
  41. core/container.py +277 -0
  42. core/database.py +114 -0
  43. core/input_validator.py +353 -0
  44. core/models.py +288 -0
  45. core/orchestrator.py +611 -0
  46. core/plugin_manager.py +571 -0
  47. core/rate_limiter.py +405 -0
  48. core/secure_config.py +328 -0
  49. core/shield_integration.py +296 -0
  50. modules/__init__.py +46 -0
  51. modules/cve_database.py +362 -0
  52. modules/exploit_assist.py +330 -0
  53. modules/nuclei_integration.py +480 -0
  54. modules/osint.py +604 -0
  55. modules/protonvpn.py +554 -0
  56. modules/recon.py +165 -0
  57. modules/sql_injection_db.py +826 -0
  58. modules/tool_orchestrator.py +498 -0
  59. modules/vuln_scanner.py +292 -0
  60. modules/wordlist_generator.py +566 -0
  61. risk_engine/__init__.py +99 -0
  62. risk_engine/business_impact.py +267 -0
  63. risk_engine/business_impact_calculator.py +563 -0
  64. risk_engine/cvss.py +156 -0
  65. risk_engine/epss.py +190 -0
  66. risk_engine/example_usage.py +294 -0
  67. risk_engine/false_positive_engine.py +1073 -0
  68. risk_engine/scorer.py +304 -0
  69. web_ui/backend/main.py +471 -0
  70. zen_ai_pentest-2.0.0.dist-info/METADATA +795 -0
  71. zen_ai_pentest-2.0.0.dist-info/RECORD +75 -0
  72. zen_ai_pentest-2.0.0.dist-info/WHEEL +5 -0
  73. zen_ai_pentest-2.0.0.dist-info/entry_points.txt +2 -0
  74. zen_ai_pentest-2.0.0.dist-info/licenses/LICENSE +21 -0
  75. zen_ai_pentest-2.0.0.dist-info/top_level.txt +10 -0
@@ -0,0 +1,353 @@
1
+ """
2
+ Input Validation & Sanitization
3
+ Prevents injection attacks and ensures data integrity
4
+ """
5
+
6
+ import html
7
+ import logging
8
+ import re
9
+ import subprocess
10
+ from dataclasses import dataclass
11
+ from typing import Any, List, Optional, Pattern
12
+ from urllib.parse import urlparse
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass
18
+ class ValidationRule:
19
+ """Validation rule configuration"""
20
+
21
+ pattern: Pattern
22
+ max_length: int = 255
23
+ allow_empty: bool = False
24
+ error_message: str = "Invalid input"
25
+
26
+
27
+ class InputValidator:
28
+ """
29
+ Centralized input validation for all user inputs
30
+ """
31
+
32
+ # Domain regex (RFC compliant)
33
+ DOMAIN_PATTERN = re.compile(
34
+ r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*"
35
+ r"[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$"
36
+ )
37
+
38
+ # IP address regex
39
+ IP_PATTERN = re.compile(
40
+ r"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}"
41
+ r"(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"
42
+ )
43
+
44
+ # Email regex (simplified)
45
+ EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
46
+
47
+ # Safe filename regex
48
+ FILENAME_PATTERN = re.compile(r"^[\w\-. ]+$")
49
+
50
+ # Command injection pattern
51
+ DANGEROUS_CHARS = re.compile(r'[;&|`$(){}[\]\\\'"<>]')
52
+
53
+ # Path traversal pattern
54
+ PATH_TRAVERSAL = re.compile(r"\.\.|^/|\\")
55
+
56
+ def __init__(self):
57
+ self.rules: dict[str, ValidationRule] = {
58
+ "domain": ValidationRule(
59
+ pattern=self.DOMAIN_PATTERN,
60
+ max_length=253,
61
+ error_message="Invalid domain format",
62
+ ),
63
+ "ip": ValidationRule(
64
+ pattern=self.IP_PATTERN,
65
+ max_length=15,
66
+ error_message="Invalid IP address",
67
+ ),
68
+ "email": ValidationRule(
69
+ pattern=self.EMAIL_PATTERN,
70
+ max_length=254,
71
+ error_message="Invalid email format",
72
+ ),
73
+ "filename": ValidationRule(
74
+ pattern=self.FILENAME_PATTERN,
75
+ max_length=255,
76
+ error_message="Invalid filename (special chars not allowed)",
77
+ ),
78
+ }
79
+
80
+ def validate_domain(self, domain: str) -> Optional[str]:
81
+ """Validate and sanitize domain name"""
82
+ if not domain:
83
+ return None
84
+
85
+ # Strip whitespace and lowercase
86
+ domain = domain.strip().lower()
87
+
88
+ # Check length
89
+ if len(domain) > 253:
90
+ logger.warning(f"Domain too long: {domain[:50]}...")
91
+ return None
92
+
93
+ # Validate pattern
94
+ if not self.DOMAIN_PATTERN.match(domain):
95
+ logger.warning(f"Invalid domain format: {domain}")
96
+ return None
97
+
98
+ # Check for dangerous characters
99
+ if self.DANGEROUS_CHARS.search(domain):
100
+ logger.warning(f"Domain contains dangerous chars: {domain}")
101
+ return None
102
+
103
+ return domain
104
+
105
+ def validate_ip(self, ip: str) -> Optional[str]:
106
+ """Validate IP address"""
107
+ if not ip:
108
+ return None
109
+
110
+ ip = ip.strip()
111
+
112
+ if not self.IP_PATTERN.match(ip):
113
+ logger.warning(f"Invalid IP format: {ip}")
114
+ return None
115
+
116
+ return ip
117
+
118
+ def validate_email(self, email: str) -> Optional[str]:
119
+ """Validate email address"""
120
+ if not email:
121
+ return None
122
+
123
+ email = email.strip().lower()
124
+
125
+ if len(email) > 254:
126
+ logger.warning(f"Email too long: {email[:50]}...")
127
+ return None
128
+
129
+ if not self.EMAIL_PATTERN.match(email):
130
+ logger.warning(f"Invalid email format: {email}")
131
+ return None
132
+
133
+ return email
134
+
135
+ def validate_url(
136
+ self, url: str, allowed_schemes: List[str] = None
137
+ ) -> Optional[str]:
138
+ """Validate and sanitize URL"""
139
+ if not url:
140
+ return None
141
+
142
+ allowed_schemes = allowed_schemes or ["http", "https"]
143
+
144
+ try:
145
+ parsed = urlparse(url.strip())
146
+
147
+ # Check scheme
148
+ if parsed.scheme not in allowed_schemes:
149
+ logger.warning(f"URL scheme not allowed: {parsed.scheme}")
150
+ return None
151
+
152
+ # Validate hostname
153
+ if not parsed.hostname:
154
+ logger.warning(f"URL missing hostname: {url}")
155
+ return None
156
+
157
+ if not self.validate_domain(parsed.hostname):
158
+ logger.warning(f"Invalid URL hostname: {parsed.hostname}")
159
+ return None
160
+
161
+ # Reconstruct safe URL
162
+ safe_url = f"{parsed.scheme}://{parsed.hostname}"
163
+ if parsed.port:
164
+ safe_url += f":{parsed.port}"
165
+ if parsed.path:
166
+ # Sanitize path
167
+ safe_path = self.sanitize_path(parsed.path)
168
+ safe_url += safe_path
169
+
170
+ return safe_url
171
+
172
+ except Exception as e:
173
+ logger.warning(f"URL validation error: {e}")
174
+ return None
175
+
176
+ def validate_filename(self, filename: str) -> Optional[str]:
177
+ """Validate filename to prevent path traversal"""
178
+ if not filename:
179
+ return None
180
+
181
+ # Strip path components
182
+ filename = filename.split("/")[-1].split("\\")[-1]
183
+
184
+ if not self.FILENAME_PATTERN.match(filename):
185
+ logger.warning(f"Invalid filename: {filename}")
186
+ return None
187
+
188
+ # Check for path traversal
189
+ if self.PATH_TRAVERSAL.search(filename):
190
+ logger.warning(f"Path traversal detected: {filename}")
191
+ return None
192
+
193
+ return filename
194
+
195
+ def sanitize_path(self, path: str) -> str:
196
+ """Sanitize file path"""
197
+ if not path:
198
+ return "/"
199
+
200
+ # Normalize
201
+ path = path.replace("\\", "/")
202
+
203
+ # Remove path traversal
204
+ parts = []
205
+ for part in path.split("/"):
206
+ if part == ".." or part == ".":
207
+ continue
208
+ if part:
209
+ parts.append(part)
210
+
211
+ return "/" + "/".join(parts)
212
+
213
+ def sanitize_for_shell(self, value: str) -> str:
214
+ """
215
+ Sanitize string for safe use in shell commands.
216
+ Uses shell=False approach internally.
217
+ """
218
+ if not value:
219
+ return ""
220
+
221
+ # Remove all shell metacharacters
222
+ sanitized = self.DANGEROUS_CHARS.sub("", value)
223
+
224
+ return sanitized
225
+
226
+ def escape_html(self, text: str) -> str:
227
+ """Escape HTML entities to prevent XSS"""
228
+ return html.escape(str(text), quote=True)
229
+
230
+ def sanitize_llm_output(self, output: str, allowed_tags: List[str] = None) -> str:
231
+ """
232
+ Sanitize LLM output before storage/display.
233
+ Removes potentially dangerous content.
234
+ """
235
+ if not output:
236
+ return ""
237
+
238
+ # Remove null bytes
239
+ output = output.replace("\x00", "")
240
+
241
+ # Remove control characters except newlines and tabs
242
+ output = "".join(
243
+ char
244
+ for char in output
245
+ if char == "\n" or char == "\t" or (ord(char) >= 32 and ord(char) < 127)
246
+ )
247
+
248
+ # Escape HTML
249
+ output = self.escape_html(output)
250
+
251
+ return output
252
+
253
+
254
+ class SecureSubprocess:
255
+ """
256
+ Secure subprocess execution wrapper.
257
+ Prevents shell injection by using shell=False and argument lists.
258
+ """
259
+
260
+ @staticmethod
261
+ def run(
262
+ command: List[str],
263
+ cwd: Optional[str] = None,
264
+ env: Optional[dict] = None,
265
+ timeout: int = 300,
266
+ capture_output: bool = True,
267
+ check: bool = False,
268
+ ) -> subprocess.CompletedProcess:
269
+ """
270
+ Execute command securely with shell=False.
271
+
272
+ Args:
273
+ command: List of command arguments (NEVER use shell=True)
274
+ cwd: Working directory
275
+ env: Environment variables
276
+ timeout: Timeout in seconds
277
+ capture_output: Capture stdout/stderr
278
+ check: Raise exception on non-zero exit
279
+
280
+ Returns:
281
+ CompletedProcess instance
282
+
283
+ Raises:
284
+ subprocess.SubprocessError: On execution failure
285
+ """
286
+ if not command:
287
+ raise ValueError("Command cannot be empty")
288
+
289
+ # Validate all arguments
290
+ validator = InputValidator()
291
+ for arg in command:
292
+ if validator.DANGEROUS_CHARS.search(str(arg)):
293
+ logger.error(f"Dangerous character in command argument: {arg}")
294
+ raise ValueError(f"Invalid command argument: {arg}")
295
+
296
+ logger.debug(f"Executing: {' '.join(command)}")
297
+
298
+ try:
299
+ return subprocess.run(
300
+ command,
301
+ cwd=cwd,
302
+ env=env,
303
+ timeout=timeout,
304
+ capture_output=capture_output,
305
+ text=True,
306
+ check=check,
307
+ shell=False, # NEVER use shell=True
308
+ )
309
+ except subprocess.TimeoutExpired as e:
310
+ logger.error(f"Command timeout after {timeout}s: {command[0]}")
311
+ raise
312
+ except subprocess.CalledProcessError as e:
313
+ logger.error(f"Command failed: {e}")
314
+ raise
315
+ except Exception as e:
316
+ logger.error(f"Subprocess error: {e}")
317
+ raise
318
+
319
+ @staticmethod
320
+ def validate_nuclei_args(args: List[str]) -> bool:
321
+ """
322
+ Validate Nuclei arguments for security.
323
+ Blocks dangerous flags.
324
+ """
325
+ blocked_flags = [
326
+ "-shell",
327
+ "-exec",
328
+ "-command",
329
+ "-cmd",
330
+ "--shell",
331
+ "--exec",
332
+ "--command",
333
+ "--cmd",
334
+ ]
335
+
336
+ for arg in args:
337
+ if any(arg.startswith(blocked) for blocked in blocked_flags):
338
+ logger.error(f"Blocked dangerous Nuclei flag: {arg}")
339
+ return False
340
+
341
+ return True
342
+
343
+
344
+ # Global validator instance
345
+ _validator = None
346
+
347
+
348
+ def get_validator() -> InputValidator:
349
+ """Get global validator instance"""
350
+ global _validator
351
+ if _validator is None:
352
+ _validator = InputValidator()
353
+ return _validator
core/models.py ADDED
@@ -0,0 +1,288 @@
1
+ """
2
+ Pydantic Models for Type Safety and Validation
3
+ All configuration, API requests/responses use these models
4
+ """
5
+
6
+ import re
7
+ from datetime import datetime
8
+ from enum import Enum
9
+ from typing import Any, Dict, List, Literal, Optional
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
12
+
13
+
14
+ # Enums
15
+ class Severity(str, Enum):
16
+ CRITICAL = "critical"
17
+ HIGH = "high"
18
+ MEDIUM = "medium"
19
+ LOW = "low"
20
+ INFO = "info"
21
+
22
+
23
+ class ScanStatus(str, Enum):
24
+ PENDING = "pending"
25
+ RUNNING = "running"
26
+ COMPLETED = "completed"
27
+ FAILED = "failed"
28
+ CANCELLED = "cancelled"
29
+
30
+
31
+ class BackendType(str, Enum):
32
+ DUCKDUCKGO = "duckduckgo"
33
+ OPENROUTER = "openrouter"
34
+ OPENAI = "openai"
35
+ ANTHROPIC = "anthropic"
36
+
37
+
38
+ # Base Models
39
+ class TimestampedModel(BaseModel):
40
+ """Base model with timestamps"""
41
+
42
+ model_config = ConfigDict(from_attributes=True)
43
+
44
+ created_at: datetime = Field(default_factory=datetime.utcnow)
45
+ updated_at: Optional[datetime] = None
46
+
47
+
48
+ class APIKeyConfig(BaseModel):
49
+ """Secure API key configuration"""
50
+
51
+ model_config = ConfigDict(extra="forbid")
52
+
53
+ openrouter_key: Optional[str] = Field(None, pattern=r"^sk-or-[a-zA-Z0-9]{20,}$")
54
+ openai_key: Optional[str] = Field(None, pattern=r"^sk-[a-zA-Z0-9]{20,}$")
55
+ anthropic_key: Optional[str] = Field(None, pattern=r"^sk-ant-[a-zA-Z0-9]{20,}$")
56
+ github_token: Optional[str] = Field(None, min_length=20)
57
+ shodan_key: Optional[str] = None
58
+
59
+ @field_validator("*")
60
+ @classmethod
61
+ def mask_keys(cls, v: Optional[str]) -> Optional[str]:
62
+ """Mask API keys in logs"""
63
+ if v and len(v) > 10:
64
+ return v # Return full value, masking happens in repr
65
+ return v
66
+
67
+ def get_key(self, provider: BackendType) -> Optional[str]:
68
+ """Get API key for provider"""
69
+ mapping = {
70
+ BackendType.OPENROUTER: self.openrouter_key,
71
+ BackendType.OPENAI: self.openai_key,
72
+ BackendType.ANTHROPIC: self.anthropic_key,
73
+ }
74
+ return mapping.get(provider)
75
+
76
+
77
+ class ScanConfig(BaseModel):
78
+ """Scan configuration with validation"""
79
+
80
+ model_config = ConfigDict(extra="forbid")
81
+
82
+ target: str = Field(..., min_length=1, max_length=253)
83
+ scan_type: Literal["quick", "full", "stealth"] = "quick"
84
+ ports: List[int] = Field(default_factory=lambda: [80, 443])
85
+ templates: List[str] = Field(default_factory=list)
86
+ timeout: int = Field(300, ge=10, le=3600)
87
+ concurrent: int = Field(5, ge=1, le=50)
88
+ follow_redirects: bool = True
89
+
90
+ @field_validator("target")
91
+ @classmethod
92
+ def validate_target(cls, v: str) -> str:
93
+ """Validate target is domain or IP"""
94
+ v = v.strip().lower()
95
+
96
+ # Check for dangerous characters
97
+ if re.search(r'[;&|`$(){}[\]\\\'"<>]', v):
98
+ raise ValueError("Target contains dangerous characters")
99
+
100
+ # Simple domain validation
101
+ if not re.match(r"^[a-z0-9][a-z0-9.-]*[a-z0-9]$", v):
102
+ # Could be IP
103
+ if not re.match(r"^(\d{1,3}\.){3}\d{1,3}$", v):
104
+ raise ValueError("Invalid target format")
105
+
106
+ return v
107
+
108
+ @field_validator("ports")
109
+ @classmethod
110
+ def validate_ports(cls, v: List[int]) -> List[int]:
111
+ """Validate port numbers"""
112
+ for port in v:
113
+ if not 1 <= port <= 65535:
114
+ raise ValueError(f"Invalid port: {port}")
115
+ return v
116
+
117
+
118
+ class Finding(BaseModel):
119
+ """Security finding/vulnerability"""
120
+
121
+ model_config = ConfigDict(from_attributes=True)
122
+
123
+ id: Optional[str] = None
124
+ title: str = Field(..., min_length=1, max_length=500)
125
+ description: str = Field(..., min_length=1)
126
+ severity: Severity
127
+ cvss_score: Optional[float] = Field(None, ge=0, le=10)
128
+ host: str
129
+ port: Optional[int] = Field(None, ge=1, le=65535)
130
+ service: Optional[str] = None
131
+ evidence: Optional[str] = None
132
+ remediation: Optional[str] = None
133
+ references: List[str] = Field(default_factory=list)
134
+ cve_ids: List[str] = Field(default_factory=list)
135
+ tags: List[str] = Field(default_factory=list)
136
+ confidence: Literal["confirmed", "likely", "possible"] = "possible"
137
+
138
+ @field_validator("cve_ids")
139
+ @classmethod
140
+ def validate_cve_format(cls, v: List[str]) -> List[str]:
141
+ """Validate CVE ID format"""
142
+ for cve in v:
143
+ if not re.match(r"^CVE-\d{4}-\d{4,}$", cve, re.IGNORECASE):
144
+ raise ValueError(f"Invalid CVE format: {cve}")
145
+ return [cve.upper() for cve in v]
146
+
147
+
148
+ class ScanResult(BaseModel):
149
+ """Complete scan result"""
150
+
151
+ model_config = ConfigDict(from_attributes=True)
152
+
153
+ scan_id: str
154
+ target: str
155
+ status: ScanStatus
156
+ started_at: datetime
157
+ completed_at: Optional[datetime] = None
158
+ findings: List[Finding] = Field(default_factory=list)
159
+ stats: Dict[str, Any] = Field(default_factory=dict)
160
+ error_message: Optional[str] = None
161
+
162
+ @property
163
+ def duration_seconds(self) -> Optional[float]:
164
+ """Calculate scan duration"""
165
+ if self.completed_at:
166
+ return (self.completed_at - self.started_at).total_seconds()
167
+ return None
168
+
169
+ @property
170
+ def severity_counts(self) -> Dict[str, int]:
171
+ """Count findings by severity"""
172
+ counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
173
+ for finding in self.findings:
174
+ counts[finding.severity.value] += 1
175
+ return counts
176
+
177
+
178
+ class LLMRequest(BaseModel):
179
+ """LLM request with validation"""
180
+
181
+ model_config = ConfigDict(extra="forbid")
182
+
183
+ prompt: str = Field(..., min_length=1, max_length=10000)
184
+ system_prompt: Optional[str] = Field(None, max_length=5000)
185
+ temperature: float = Field(0.7, ge=0, le=2)
186
+ max_tokens: Optional[int] = Field(None, ge=1, le=32000)
187
+ backend: Optional[BackendType] = None
188
+
189
+ @field_validator("prompt")
190
+ @classmethod
191
+ def sanitize_prompt(cls, v: str) -> str:
192
+ """Basic prompt sanitization"""
193
+ # Remove null bytes
194
+ v = v.replace("\x00", "")
195
+ # Remove control chars except newlines/tabs
196
+ v = "".join(c for c in v if c == "\n" or c == "\t" or ord(c) >= 32)
197
+ return v.strip()
198
+
199
+
200
+ class LLMResponse(BaseModel):
201
+ """LLM response model"""
202
+
203
+ model_config = ConfigDict(from_attributes=True)
204
+
205
+ content: str
206
+ backend: BackendType
207
+ model: Optional[str] = None
208
+ tokens_used: Optional[int] = None
209
+ latency_ms: Optional[float] = None
210
+ cached: bool = False
211
+ error: Optional[str] = None
212
+
213
+ @property
214
+ def success(self) -> bool:
215
+ return self.error is None
216
+
217
+
218
+ class SubdomainInfo(BaseModel):
219
+ """Subdomain information"""
220
+
221
+ name: str
222
+ ip_addresses: List[str] = Field(default_factory=list)
223
+ technologies: List[str] = Field(default_factory=list)
224
+ ports: List[int] = Field(default_factory=list)
225
+ is_alive: bool = False
226
+
227
+
228
+ class DomainRecon(BaseModel):
229
+ """Domain reconnaissance results"""
230
+
231
+ domain: str
232
+ registrar: Optional[str] = None
233
+ creation_date: Optional[datetime] = None
234
+ expiration_date: Optional[datetime] = None
235
+ name_servers: List[str] = Field(default_factory=list)
236
+ subdomains: List[SubdomainInfo] = Field(default_factory=list)
237
+ emails: List[str] = Field(default_factory=list)
238
+ technologies: List[str] = Field(default_factory=list)
239
+
240
+
241
+ class HealthStatus(BaseModel):
242
+ """System health status"""
243
+
244
+ status: Literal["healthy", "degraded", "unhealthy"]
245
+ version: str
246
+ uptime_seconds: float
247
+ checks: Dict[str, bool] = Field(default_factory=dict)
248
+ backends: Dict[str, str] = Field(default_factory=dict)
249
+
250
+
251
+ class PaginatedResponse(BaseModel):
252
+ """Paginated API response"""
253
+
254
+ items: List[Any]
255
+ total: int
256
+ page: int
257
+ per_page: int
258
+ pages: int
259
+
260
+ @property
261
+ def has_next(self) -> bool:
262
+ return self.page < self.pages
263
+
264
+ @property
265
+ def has_prev(self) -> bool:
266
+ return self.page > 1
267
+
268
+
269
+ class ReportConfig(BaseModel):
270
+ """Report generation configuration"""
271
+
272
+ model_config = ConfigDict(extra="forbid")
273
+
274
+ title: str = Field(..., min_length=1, max_length=200)
275
+ client_name: str = Field(..., min_length=1, max_length=200)
276
+ format: Literal["markdown", "html", "pdf", "json"] = "markdown"
277
+ template: str = "technical"
278
+ include_evidence: bool = True
279
+ include_remediation: bool = True
280
+ severity_filter: Optional[List[Severity]] = None
281
+
282
+ @field_validator("template")
283
+ @classmethod
284
+ def validate_template(cls, v: str) -> str:
285
+ allowed = ["executive", "technical", "detailed"]
286
+ if v not in allowed:
287
+ raise ValueError(f"Template must be one of: {allowed}")
288
+ return v