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.
- agents/__init__.py +28 -0
- agents/agent_base.py +239 -0
- agents/agent_orchestrator.py +346 -0
- agents/analysis_agent.py +225 -0
- agents/cli.py +258 -0
- agents/exploit_agent.py +224 -0
- agents/integration.py +211 -0
- agents/post_scan_agent.py +937 -0
- agents/react_agent.py +384 -0
- agents/react_agent_enhanced.py +616 -0
- agents/react_agent_vm.py +298 -0
- agents/research_agent.py +176 -0
- api/__init__.py +11 -0
- api/auth.py +123 -0
- api/main.py +1027 -0
- api/schemas.py +357 -0
- api/websocket.py +97 -0
- autonomous/__init__.py +122 -0
- autonomous/agent.py +253 -0
- autonomous/agent_loop.py +1370 -0
- autonomous/exploit_validator.py +1537 -0
- autonomous/memory.py +448 -0
- autonomous/react.py +339 -0
- autonomous/tool_executor.py +488 -0
- backends/__init__.py +16 -0
- backends/chatgpt_direct.py +133 -0
- backends/claude_direct.py +130 -0
- backends/duckduckgo.py +138 -0
- backends/openrouter.py +120 -0
- benchmarks/__init__.py +149 -0
- benchmarks/benchmark_engine.py +904 -0
- benchmarks/ci_benchmark.py +785 -0
- benchmarks/comparison.py +729 -0
- benchmarks/metrics.py +553 -0
- benchmarks/run_benchmarks.py +809 -0
- ci_cd/__init__.py +2 -0
- core/__init__.py +17 -0
- core/async_pool.py +282 -0
- core/asyncio_fix.py +222 -0
- core/cache.py +472 -0
- core/container.py +277 -0
- core/database.py +114 -0
- core/input_validator.py +353 -0
- core/models.py +288 -0
- core/orchestrator.py +611 -0
- core/plugin_manager.py +571 -0
- core/rate_limiter.py +405 -0
- core/secure_config.py +328 -0
- core/shield_integration.py +296 -0
- modules/__init__.py +46 -0
- modules/cve_database.py +362 -0
- modules/exploit_assist.py +330 -0
- modules/nuclei_integration.py +480 -0
- modules/osint.py +604 -0
- modules/protonvpn.py +554 -0
- modules/recon.py +165 -0
- modules/sql_injection_db.py +826 -0
- modules/tool_orchestrator.py +498 -0
- modules/vuln_scanner.py +292 -0
- modules/wordlist_generator.py +566 -0
- risk_engine/__init__.py +99 -0
- risk_engine/business_impact.py +267 -0
- risk_engine/business_impact_calculator.py +563 -0
- risk_engine/cvss.py +156 -0
- risk_engine/epss.py +190 -0
- risk_engine/example_usage.py +294 -0
- risk_engine/false_positive_engine.py +1073 -0
- risk_engine/scorer.py +304 -0
- web_ui/backend/main.py +471 -0
- zen_ai_pentest-2.0.0.dist-info/METADATA +795 -0
- zen_ai_pentest-2.0.0.dist-info/RECORD +75 -0
- zen_ai_pentest-2.0.0.dist-info/WHEEL +5 -0
- zen_ai_pentest-2.0.0.dist-info/entry_points.txt +2 -0
- zen_ai_pentest-2.0.0.dist-info/licenses/LICENSE +21 -0
- zen_ai_pentest-2.0.0.dist-info/top_level.txt +10 -0
core/input_validator.py
ADDED
|
@@ -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
|