zen-ai-pentest 2.2.0__py3-none-any.whl → 2.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- api/auth.py +61 -7
- api/csrf_protection.py +286 -0
- api/main.py +77 -11
- api/rate_limiter.py +317 -0
- api/rate_limiter_v2.py +586 -0
- autonomous/ki_analysis_agent.py +1033 -0
- benchmarks/__init__.py +12 -142
- benchmarks/agent_performance.py +374 -0
- benchmarks/api_performance.py +479 -0
- benchmarks/scan_performance.py +272 -0
- modules/agent_coordinator.py +255 -0
- modules/api_key_manager.py +501 -0
- modules/benchmark.py +706 -0
- modules/cve_updater.py +303 -0
- modules/false_positive_filter.py +149 -0
- modules/output_formats.py +1088 -0
- modules/risk_scoring.py +206 -0
- {zen_ai_pentest-2.2.0.dist-info → zen_ai_pentest-2.3.0.dist-info}/METADATA +134 -289
- {zen_ai_pentest-2.2.0.dist-info → zen_ai_pentest-2.3.0.dist-info}/RECORD +23 -9
- {zen_ai_pentest-2.2.0.dist-info → zen_ai_pentest-2.3.0.dist-info}/WHEEL +0 -0
- {zen_ai_pentest-2.2.0.dist-info → zen_ai_pentest-2.3.0.dist-info}/entry_points.txt +0 -0
- {zen_ai_pentest-2.2.0.dist-info → zen_ai_pentest-2.3.0.dist-info}/licenses/LICENSE +0 -0
- {zen_ai_pentest-2.2.0.dist-info → zen_ai_pentest-2.3.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1088 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Output Formatters Module for CI/CD Integration
|
|
3
|
+
==============================================
|
|
4
|
+
Provides formatters for various CI/CD platforms:
|
|
5
|
+
- SARIF (GitHub Security, GitLab Security Dashboard)
|
|
6
|
+
- JUnit XML (Jenkins, GitLab Test Reports)
|
|
7
|
+
- HTML (Human readable reports)
|
|
8
|
+
|
|
9
|
+
Version: 2.0.0
|
|
10
|
+
Author: Zen-AI-Pentest Team
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import xml.etree.ElementTree as ET
|
|
15
|
+
import xml.dom.minidom as minidom
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from typing import List, Dict, Any, Optional, Union
|
|
18
|
+
from dataclasses import dataclass, asdict
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
import html
|
|
21
|
+
import hashlib
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Finding:
|
|
26
|
+
"""Security finding data structure"""
|
|
27
|
+
id: str
|
|
28
|
+
title: str
|
|
29
|
+
description: str
|
|
30
|
+
severity: str # critical, high, medium, low, info
|
|
31
|
+
target: str
|
|
32
|
+
category: str
|
|
33
|
+
cve_id: Optional[str] = None
|
|
34
|
+
cvss_score: Optional[float] = None
|
|
35
|
+
remediation: Optional[str] = None
|
|
36
|
+
references: Optional[List[str]] = None
|
|
37
|
+
discovered_at: Optional[str] = None
|
|
38
|
+
evidence: Optional[Dict[str, Any]] = None
|
|
39
|
+
location: Optional[Dict[str, Any]] = None # For SARIF location
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ScanSummary:
|
|
44
|
+
"""Scan summary data structure"""
|
|
45
|
+
scan_id: str
|
|
46
|
+
target: str
|
|
47
|
+
scan_date: str
|
|
48
|
+
duration: int # seconds
|
|
49
|
+
total_findings: int
|
|
50
|
+
critical: int
|
|
51
|
+
high: int
|
|
52
|
+
medium: int
|
|
53
|
+
low: int
|
|
54
|
+
info: int
|
|
55
|
+
risk_score: int
|
|
56
|
+
tool_name: str = "Zen AI Pentest"
|
|
57
|
+
tool_version: str = "2.0.0"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SARIFFormatter:
|
|
61
|
+
"""
|
|
62
|
+
SARIF (Static Analysis Results Interchange Format) formatter.
|
|
63
|
+
|
|
64
|
+
SARIF is the standard format for GitHub Advanced Security and
|
|
65
|
+
GitLab Security Dashboard integration.
|
|
66
|
+
|
|
67
|
+
Spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
SARIF_VERSION = "2.1.0"
|
|
71
|
+
SCHEMA_URI = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json"
|
|
72
|
+
|
|
73
|
+
SEVERITY_LEVELS = {
|
|
74
|
+
"critical": {"level": "error", "rank": 10},
|
|
75
|
+
"high": {"level": "error", "rank": 8},
|
|
76
|
+
"medium": {"level": "warning", "rank": 5},
|
|
77
|
+
"low": {"level": "note", "rank": 3},
|
|
78
|
+
"info": {"level": "none", "rank": 1}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
def __init__(self, tool_name: str = "Zen AI Pentest", tool_version: str = "2.0.0"):
|
|
82
|
+
self.tool_name = tool_name
|
|
83
|
+
self.tool_version = tool_version
|
|
84
|
+
|
|
85
|
+
def format(self, findings: List[Finding], summary: ScanSummary) -> Dict[str, Any]:
|
|
86
|
+
"""Convert findings to SARIF format"""
|
|
87
|
+
|
|
88
|
+
sarif = {
|
|
89
|
+
"$schema": self.SCHEMA_URI,
|
|
90
|
+
"version": self.SARIF_VERSION,
|
|
91
|
+
"runs": [{
|
|
92
|
+
"tool": {
|
|
93
|
+
"driver": {
|
|
94
|
+
"name": self.tool_name,
|
|
95
|
+
"version": self.tool_version,
|
|
96
|
+
"informationUri": "https://github.com/zen-ai-pentest/zen-ai-pentest",
|
|
97
|
+
"rules": self._generate_rules(findings)
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"results": self._generate_results(findings),
|
|
101
|
+
"invocations": [{
|
|
102
|
+
"executionSuccessful": True,
|
|
103
|
+
"startTimeUtc": summary.scan_date,
|
|
104
|
+
"endTimeUtc": self._calculate_end_time(summary.scan_date, summary.duration)
|
|
105
|
+
}],
|
|
106
|
+
"properties": {
|
|
107
|
+
"scanId": summary.scan_id,
|
|
108
|
+
"target": summary.target,
|
|
109
|
+
"riskScore": summary.risk_score,
|
|
110
|
+
"summary": {
|
|
111
|
+
"critical": summary.critical,
|
|
112
|
+
"high": summary.high,
|
|
113
|
+
"medium": summary.medium,
|
|
114
|
+
"low": summary.low,
|
|
115
|
+
"info": summary.info
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return sarif
|
|
122
|
+
|
|
123
|
+
def _generate_rules(self, findings: List[Finding]) -> List[Dict[str, Any]]:
|
|
124
|
+
"""Generate SARIF rules from unique finding types"""
|
|
125
|
+
rules = []
|
|
126
|
+
seen_categories = set()
|
|
127
|
+
|
|
128
|
+
for finding in findings:
|
|
129
|
+
if finding.category in seen_categories:
|
|
130
|
+
continue
|
|
131
|
+
seen_categories.add(finding.category)
|
|
132
|
+
|
|
133
|
+
rule = {
|
|
134
|
+
"id": self._sanitize_rule_id(finding.category),
|
|
135
|
+
"name": finding.category,
|
|
136
|
+
"shortDescription": {
|
|
137
|
+
"text": finding.category
|
|
138
|
+
},
|
|
139
|
+
"fullDescription": {
|
|
140
|
+
"text": f"Security issue category: {finding.category}"
|
|
141
|
+
},
|
|
142
|
+
"defaultConfiguration": {
|
|
143
|
+
"level": self.SEVERITY_LEVELS.get(
|
|
144
|
+
finding.severity.lower(), {"level": "warning"}
|
|
145
|
+
)["level"]
|
|
146
|
+
},
|
|
147
|
+
"properties": {
|
|
148
|
+
"category": finding.category,
|
|
149
|
+
"severity": finding.severity
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
rules.append(rule)
|
|
153
|
+
|
|
154
|
+
return rules
|
|
155
|
+
|
|
156
|
+
def _generate_results(self, findings: List[Finding]) -> List[Dict[str, Any]]:
|
|
157
|
+
"""Generate SARIF results from findings"""
|
|
158
|
+
results = []
|
|
159
|
+
|
|
160
|
+
for idx, finding in enumerate(findings, 1):
|
|
161
|
+
result = {
|
|
162
|
+
"ruleId": self._sanitize_rule_id(finding.category),
|
|
163
|
+
"ruleIndex": 0,
|
|
164
|
+
"message": {
|
|
165
|
+
"text": finding.title,
|
|
166
|
+
"markdown": f"**{finding.title}**\n\n{finding.description}"
|
|
167
|
+
},
|
|
168
|
+
"locations": self._generate_locations(finding),
|
|
169
|
+
"level": self.SEVERITY_LEVELS.get(
|
|
170
|
+
finding.severity.lower(), {"level": "warning"}
|
|
171
|
+
)["level"],
|
|
172
|
+
"rank": self.SEVERITY_LEVELS.get(
|
|
173
|
+
finding.severity.lower(), {"rank": 5}
|
|
174
|
+
)["rank"],
|
|
175
|
+
"properties": {
|
|
176
|
+
"findingId": finding.id,
|
|
177
|
+
"severity": finding.severity,
|
|
178
|
+
"target": finding.target,
|
|
179
|
+
"cveId": finding.cve_id,
|
|
180
|
+
"cvssScore": finding.cvss_score,
|
|
181
|
+
"discoveredAt": finding.discovered_at
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# Add remediation if available
|
|
186
|
+
if finding.remediation:
|
|
187
|
+
result["fixes"] = [{
|
|
188
|
+
"description": {
|
|
189
|
+
"text": finding.remediation
|
|
190
|
+
}
|
|
191
|
+
}]
|
|
192
|
+
|
|
193
|
+
# Add references if available
|
|
194
|
+
if finding.references:
|
|
195
|
+
result["relatedLocations"] = [
|
|
196
|
+
{
|
|
197
|
+
"id": i + 1,
|
|
198
|
+
"physicalLocation": {
|
|
199
|
+
"artifactLocation": {
|
|
200
|
+
"uri": ref,
|
|
201
|
+
"description": {
|
|
202
|
+
"text": f"Reference {i + 1}"
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for i, ref in enumerate(finding.references)
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
results.append(result)
|
|
211
|
+
|
|
212
|
+
return results
|
|
213
|
+
|
|
214
|
+
def _generate_locations(self, finding: Finding) -> List[Dict[str, Any]]:
|
|
215
|
+
"""Generate SARIF location from finding"""
|
|
216
|
+
locations = []
|
|
217
|
+
|
|
218
|
+
# Primary location from target
|
|
219
|
+
location = {
|
|
220
|
+
"physicalLocation": {
|
|
221
|
+
"artifactLocation": {
|
|
222
|
+
"uri": finding.target,
|
|
223
|
+
"description": {
|
|
224
|
+
"text": f"Target: {finding.target}"
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# Add specific location if available
|
|
231
|
+
if finding.location:
|
|
232
|
+
if "uri" in finding.location:
|
|
233
|
+
location["physicalLocation"]["artifactLocation"]["uri"] = finding.location["uri"]
|
|
234
|
+
if "region" in finding.location:
|
|
235
|
+
location["physicalLocation"]["region"] = finding.location["region"]
|
|
236
|
+
|
|
237
|
+
locations.append(location)
|
|
238
|
+
return locations
|
|
239
|
+
|
|
240
|
+
def _sanitize_rule_id(self, category: str) -> str:
|
|
241
|
+
"""Convert category to valid SARIF rule ID"""
|
|
242
|
+
return category.lower().replace(" ", "-").replace("_", "-")[:50]
|
|
243
|
+
|
|
244
|
+
def _calculate_end_time(self, start_time: str, duration: int) -> str:
|
|
245
|
+
"""Calculate end time from start time and duration"""
|
|
246
|
+
try:
|
|
247
|
+
start = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
|
|
248
|
+
end = start.timestamp() + duration
|
|
249
|
+
return datetime.fromtimestamp(end).isoformat()
|
|
250
|
+
except:
|
|
251
|
+
return start_time
|
|
252
|
+
|
|
253
|
+
def write(self, findings: List[Finding], summary: ScanSummary,
|
|
254
|
+
output_path: Union[str, Path]) -> Path:
|
|
255
|
+
"""Write SARIF output to file"""
|
|
256
|
+
output_path = Path(output_path)
|
|
257
|
+
sarif_data = self.format(findings, summary)
|
|
258
|
+
|
|
259
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
260
|
+
json.dump(sarif_data, f, indent=2, ensure_ascii=False)
|
|
261
|
+
|
|
262
|
+
return output_path
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class JUnitXMLFormatter:
|
|
266
|
+
"""
|
|
267
|
+
JUnit XML formatter for CI test result integration.
|
|
268
|
+
|
|
269
|
+
Compatible with Jenkins, GitLab CI Test Reports, and most CI platforms.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
SEVERITY_WEIGHTS = {
|
|
273
|
+
"critical": 100,
|
|
274
|
+
"high": 50,
|
|
275
|
+
"medium": 20,
|
|
276
|
+
"low": 5,
|
|
277
|
+
"info": 1
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
def __init__(self, tool_name: str = "Zen AI Pentest"):
|
|
281
|
+
self.tool_name = tool_name
|
|
282
|
+
|
|
283
|
+
def format(self, findings: List[Finding], summary: ScanSummary) -> ET.Element:
|
|
284
|
+
"""Convert findings to JUnit XML format"""
|
|
285
|
+
|
|
286
|
+
# Create root testsuites element
|
|
287
|
+
testsuites = ET.Element("testsuites")
|
|
288
|
+
testsuites.set("name", f"{self.tool_name} Security Scan")
|
|
289
|
+
testsuites.set("tests", str(len(findings)))
|
|
290
|
+
testsuites.set("failures", str(summary.critical + summary.high))
|
|
291
|
+
testsuites.set("errors", "0")
|
|
292
|
+
testsuites.set("time", str(summary.duration))
|
|
293
|
+
|
|
294
|
+
# Create testsuite for the scan
|
|
295
|
+
testsuite = ET.SubElement(testsuites, "testsuite")
|
|
296
|
+
testsuite.set("name", f"Security Scan - {summary.target}")
|
|
297
|
+
testsuite.set("tests", str(len(findings)))
|
|
298
|
+
testsuite.set("failures", str(summary.critical + summary.high))
|
|
299
|
+
testsuite.set("errors", "0")
|
|
300
|
+
testsuite.set("time", str(summary.duration))
|
|
301
|
+
testsuite.set("timestamp", summary.scan_date)
|
|
302
|
+
testsuite.set("hostname", summary.target)
|
|
303
|
+
|
|
304
|
+
# Add properties
|
|
305
|
+
properties = ET.SubElement(testsuite, "properties")
|
|
306
|
+
self._add_property(properties, "scan_id", summary.scan_id)
|
|
307
|
+
self._add_property(properties, "target", summary.target)
|
|
308
|
+
self._add_property(properties, "risk_score", str(summary.risk_score))
|
|
309
|
+
self._add_property(properties, "tool_version", summary.tool_version)
|
|
310
|
+
|
|
311
|
+
# Add test cases for each finding
|
|
312
|
+
for finding in findings:
|
|
313
|
+
testcase = self._create_testcase(testsuite, finding, summary)
|
|
314
|
+
|
|
315
|
+
# Add summary test case
|
|
316
|
+
summary_case = ET.SubElement(testsuite, "testcase")
|
|
317
|
+
summary_case.set("name", "Scan Summary")
|
|
318
|
+
summary_case.set("classname", "SecurityScan")
|
|
319
|
+
summary_case.set("time", "0")
|
|
320
|
+
|
|
321
|
+
system_out = ET.SubElement(summary_case, "system-out")
|
|
322
|
+
system_out.text = self._generate_summary_text(summary)
|
|
323
|
+
|
|
324
|
+
return testsuites
|
|
325
|
+
|
|
326
|
+
def _add_property(self, properties: ET.Element, name: str, value: str):
|
|
327
|
+
"""Add a property to the testsuite"""
|
|
328
|
+
prop = ET.SubElement(properties, "property")
|
|
329
|
+
prop.set("name", name)
|
|
330
|
+
prop.set("value", value)
|
|
331
|
+
|
|
332
|
+
def _create_testcase(self, testsuite: ET.Element, finding: Finding,
|
|
333
|
+
summary: ScanSummary) -> ET.Element:
|
|
334
|
+
"""Create a JUnit test case for a finding"""
|
|
335
|
+
|
|
336
|
+
testcase = ET.SubElement(testsuite, "testcase")
|
|
337
|
+
testcase.set("name", finding.title)
|
|
338
|
+
testcase.set("classname", f"Security.{finding.category}")
|
|
339
|
+
testcase.set("time", "0")
|
|
340
|
+
|
|
341
|
+
# Add failure for critical/high findings
|
|
342
|
+
if finding.severity.lower() in ["critical", "high"]:
|
|
343
|
+
failure = ET.SubElement(testcase, "failure")
|
|
344
|
+
failure.set("type", f"security.{finding.severity.lower()}")
|
|
345
|
+
failure.set("message", f"[{finding.severity.upper()}] {finding.title}")
|
|
346
|
+
|
|
347
|
+
# Build detailed failure text
|
|
348
|
+
failure_text = self._generate_failure_text(finding)
|
|
349
|
+
failure.text = failure_text
|
|
350
|
+
|
|
351
|
+
# Add skipped for info findings (optional)
|
|
352
|
+
elif finding.severity.lower() == "info":
|
|
353
|
+
skipped = ET.SubElement(testcase, "skipped")
|
|
354
|
+
skipped.set("message", "Informational finding")
|
|
355
|
+
|
|
356
|
+
# Add system-out with finding details
|
|
357
|
+
system_out = ET.SubElement(testcase, "system-out")
|
|
358
|
+
system_out.text = self._generate_finding_text(finding)
|
|
359
|
+
|
|
360
|
+
return testcase
|
|
361
|
+
|
|
362
|
+
def _generate_failure_text(self, finding: Finding) -> str:
|
|
363
|
+
"""Generate detailed failure text for JUnit"""
|
|
364
|
+
lines = [
|
|
365
|
+
f"Finding ID: {finding.id}",
|
|
366
|
+
f"Severity: {finding.severity.upper()}",
|
|
367
|
+
f"Category: {finding.category}",
|
|
368
|
+
f"Target: {finding.target}",
|
|
369
|
+
"",
|
|
370
|
+
"Description:",
|
|
371
|
+
finding.description,
|
|
372
|
+
""
|
|
373
|
+
]
|
|
374
|
+
|
|
375
|
+
if finding.cve_id:
|
|
376
|
+
lines.extend([f"CVE: {finding.cve_id}", ""])
|
|
377
|
+
|
|
378
|
+
if finding.cvss_score:
|
|
379
|
+
lines.extend([f"CVSS Score: {finding.cvss_score}", ""])
|
|
380
|
+
|
|
381
|
+
if finding.remediation:
|
|
382
|
+
lines.extend([
|
|
383
|
+
"Remediation:",
|
|
384
|
+
finding.remediation,
|
|
385
|
+
""
|
|
386
|
+
])
|
|
387
|
+
|
|
388
|
+
if finding.references:
|
|
389
|
+
lines.extend(["References:"])
|
|
390
|
+
lines.extend([f" - {ref}" for ref in finding.references])
|
|
391
|
+
|
|
392
|
+
return "\n".join(lines)
|
|
393
|
+
|
|
394
|
+
def _generate_finding_text(self, finding: Finding) -> str:
|
|
395
|
+
"""Generate finding text for system-out"""
|
|
396
|
+
return f"""Finding Details:
|
|
397
|
+
ID: {finding.id}
|
|
398
|
+
Title: {finding.title}
|
|
399
|
+
Severity: {finding.severity}
|
|
400
|
+
Target: {finding.target}
|
|
401
|
+
Category: {finding.category}
|
|
402
|
+
"""
|
|
403
|
+
|
|
404
|
+
def _generate_summary_text(self, summary: ScanSummary) -> str:
|
|
405
|
+
"""Generate scan summary text"""
|
|
406
|
+
return f"""Scan Summary
|
|
407
|
+
============
|
|
408
|
+
Target: {summary.target}
|
|
409
|
+
Scan ID: {summary.scan_id}
|
|
410
|
+
Duration: {summary.duration}s
|
|
411
|
+
Risk Score: {summary.risk_score}/100
|
|
412
|
+
|
|
413
|
+
Findings by Severity:
|
|
414
|
+
Critical: {summary.critical}
|
|
415
|
+
High: {summary.high}
|
|
416
|
+
Medium: {summary.medium}
|
|
417
|
+
Low: {summary.low}
|
|
418
|
+
Info: {summary.info}
|
|
419
|
+
"""
|
|
420
|
+
|
|
421
|
+
def to_string(self, element: ET.Element) -> str:
|
|
422
|
+
"""Convert XML element to formatted string"""
|
|
423
|
+
# Convert to string
|
|
424
|
+
rough_string = ET.tostring(element, encoding='unicode')
|
|
425
|
+
|
|
426
|
+
# Pretty print
|
|
427
|
+
reparsed = minidom.parseString(rough_string)
|
|
428
|
+
return reparsed.toprettyxml(indent=" ")
|
|
429
|
+
|
|
430
|
+
def write(self, findings: List[Finding], summary: ScanSummary,
|
|
431
|
+
output_path: Union[str, Path]) -> Path:
|
|
432
|
+
"""Write JUnit XML output to file"""
|
|
433
|
+
output_path = Path(output_path)
|
|
434
|
+
xml_element = self.format(findings, summary)
|
|
435
|
+
xml_string = self.to_string(xml_element)
|
|
436
|
+
|
|
437
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
438
|
+
f.write(xml_string)
|
|
439
|
+
|
|
440
|
+
return output_path
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
class HTMLFormatter:
|
|
444
|
+
"""
|
|
445
|
+
HTML formatter for human-readable security reports.
|
|
446
|
+
|
|
447
|
+
Generates professional HTML reports with:
|
|
448
|
+
- Executive summary with charts
|
|
449
|
+
- Detailed findings with filtering
|
|
450
|
+
- Remediation guidance
|
|
451
|
+
- Export options
|
|
452
|
+
"""
|
|
453
|
+
|
|
454
|
+
SEVERITY_COLORS = {
|
|
455
|
+
"critical": {"bg": "#dc2626", "text": "#ffffff", "light": "#fee2e2"},
|
|
456
|
+
"high": {"bg": "#ea580c", "text": "#ffffff", "light": "#ffedd5"},
|
|
457
|
+
"medium": {"bg": "#ca8a04", "text": "#ffffff", "light": "#fef3c7"},
|
|
458
|
+
"low": {"bg": "#16a34a", "text": "#ffffff", "light": "#dcfce7"},
|
|
459
|
+
"info": {"bg": "#6b7280", "text": "#ffffff", "light": "#f3f4f6"}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
def __init__(self, tool_name: str = "Zen AI Pentest", tool_version: str = "2.0.0"):
|
|
463
|
+
self.tool_name = tool_name
|
|
464
|
+
self.tool_version = tool_version
|
|
465
|
+
|
|
466
|
+
def format(self, findings: List[Finding], summary: ScanSummary) -> str:
|
|
467
|
+
"""Generate HTML report"""
|
|
468
|
+
|
|
469
|
+
html_content = f"""<!DOCTYPE html>
|
|
470
|
+
<html lang="en">
|
|
471
|
+
<head>
|
|
472
|
+
<meta charset="UTF-8">
|
|
473
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
474
|
+
<title>Security Scan Report - {html.escape(summary.target)}</title>
|
|
475
|
+
<style>
|
|
476
|
+
{self._get_css()}
|
|
477
|
+
</style>
|
|
478
|
+
</head>
|
|
479
|
+
<body>
|
|
480
|
+
{self._generate_header(summary)}
|
|
481
|
+
{self._generate_summary(summary)}
|
|
482
|
+
{self._generate_findings(findings)}
|
|
483
|
+
{self._generate_footer(summary)}
|
|
484
|
+
<script>
|
|
485
|
+
{self._get_javascript()}
|
|
486
|
+
</script>
|
|
487
|
+
</body>
|
|
488
|
+
</html>"""
|
|
489
|
+
|
|
490
|
+
return html_content
|
|
491
|
+
|
|
492
|
+
def _get_css(self) -> str:
|
|
493
|
+
"""Get CSS styles for the report"""
|
|
494
|
+
return """
|
|
495
|
+
* {
|
|
496
|
+
margin: 0;
|
|
497
|
+
padding: 0;
|
|
498
|
+
box-sizing: border-box;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
body {
|
|
502
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
|
503
|
+
'Helvetica Neue', Arial, sans-serif;
|
|
504
|
+
line-height: 1.6;
|
|
505
|
+
color: #1f2937;
|
|
506
|
+
background: #f9fafb;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
.container {
|
|
510
|
+
max-width: 1200px;
|
|
511
|
+
margin: 0 auto;
|
|
512
|
+
padding: 20px;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
.header {
|
|
516
|
+
background: linear-gradient(135deg, #059669 0%, #047857 100%);
|
|
517
|
+
color: white;
|
|
518
|
+
padding: 40px;
|
|
519
|
+
border-radius: 12px;
|
|
520
|
+
margin-bottom: 30px;
|
|
521
|
+
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
.header h1 {
|
|
525
|
+
font-size: 2.5rem;
|
|
526
|
+
margin-bottom: 10px;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
.header-meta {
|
|
530
|
+
opacity: 0.9;
|
|
531
|
+
font-size: 1.1rem;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.summary-cards {
|
|
535
|
+
display: grid;
|
|
536
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
537
|
+
gap: 20px;
|
|
538
|
+
margin-bottom: 30px;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
.summary-card {
|
|
542
|
+
background: white;
|
|
543
|
+
border-radius: 12px;
|
|
544
|
+
padding: 24px;
|
|
545
|
+
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
|
546
|
+
border-left: 4px solid;
|
|
547
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
.summary-card:hover {
|
|
551
|
+
transform: translateY(-2px);
|
|
552
|
+
box-shadow: 0 8px 15px rgba(0,0,0,0.1);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
.summary-card.critical {{ border-left-color: #dc2626; }}
|
|
556
|
+
.summary-card.high {{ border-left-color: #ea580c; }}
|
|
557
|
+
.summary-card.medium {{ border-left-color: #ca8a04; }}
|
|
558
|
+
.summary-card.low {{ border-left-color: #16a34a; }}
|
|
559
|
+
.summary-card.info {{ border-left-color: #6b7280; }}
|
|
560
|
+
|
|
561
|
+
.summary-card .count {
|
|
562
|
+
font-size: 3rem;
|
|
563
|
+
font-weight: 700;
|
|
564
|
+
margin-bottom: 8px;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
.summary-card.critical .count {{ color: #dc2626; }}
|
|
568
|
+
.summary-card.high .count {{ color: #ea580c; }}
|
|
569
|
+
.summary-card.medium .count {{ color: #ca8a04; }}
|
|
570
|
+
.summary-card.low .count {{ color: #16a34a; }}
|
|
571
|
+
.summary-card.info .count {{ color: #6b7280; }}
|
|
572
|
+
|
|
573
|
+
.summary-card .label {
|
|
574
|
+
font-size: 0.875rem;
|
|
575
|
+
text-transform: uppercase;
|
|
576
|
+
letter-spacing: 0.05em;
|
|
577
|
+
color: #6b7280;
|
|
578
|
+
font-weight: 600;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
.risk-score {
|
|
582
|
+
background: white;
|
|
583
|
+
border-radius: 12px;
|
|
584
|
+
padding: 30px;
|
|
585
|
+
margin-bottom: 30px;
|
|
586
|
+
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
.risk-score h2 {
|
|
590
|
+
margin-bottom: 20px;
|
|
591
|
+
color: #1f2937;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
.risk-indicator {
|
|
595
|
+
display: flex;
|
|
596
|
+
align-items: center;
|
|
597
|
+
gap: 30px;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
.risk-value {
|
|
601
|
+
width: 120px;
|
|
602
|
+
height: 120px;
|
|
603
|
+
border-radius: 50%;
|
|
604
|
+
display: flex;
|
|
605
|
+
align-items: center;
|
|
606
|
+
justify-content: center;
|
|
607
|
+
font-size: 2.5rem;
|
|
608
|
+
font-weight: 700;
|
|
609
|
+
color: white;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
.risk-low {{ background: linear-gradient(135deg, #16a34a, #22c55e); }}
|
|
613
|
+
.risk-medium {{ background: linear-gradient(135deg, #ca8a04, #eab308); }}
|
|
614
|
+
.risk-high {{ background: linear-gradient(135deg, #ea580c, #f97316); }}
|
|
615
|
+
.risk-critical {{ background: linear-gradient(135deg, #dc2626, #ef4444); }}
|
|
616
|
+
|
|
617
|
+
.findings-section {
|
|
618
|
+
background: white;
|
|
619
|
+
border-radius: 12px;
|
|
620
|
+
padding: 30px;
|
|
621
|
+
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
.findings-header {
|
|
625
|
+
display: flex;
|
|
626
|
+
justify-content: space-between;
|
|
627
|
+
align-items: center;
|
|
628
|
+
margin-bottom: 20px;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
.filter-buttons {
|
|
632
|
+
display: flex;
|
|
633
|
+
gap: 10px;
|
|
634
|
+
flex-wrap: wrap;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
.filter-btn {
|
|
638
|
+
padding: 8px 16px;
|
|
639
|
+
border: 2px solid #e5e7eb;
|
|
640
|
+
background: white;
|
|
641
|
+
border-radius: 6px;
|
|
642
|
+
cursor: pointer;
|
|
643
|
+
font-weight: 500;
|
|
644
|
+
transition: all 0.2s;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
.filter-btn:hover, .filter-btn.active {
|
|
648
|
+
border-color: #059669;
|
|
649
|
+
color: #059669;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
.finding-item {
|
|
653
|
+
border: 1px solid #e5e7eb;
|
|
654
|
+
border-radius: 8px;
|
|
655
|
+
margin-bottom: 16px;
|
|
656
|
+
overflow: hidden;
|
|
657
|
+
transition: box-shadow 0.2s;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
.finding-item:hover {
|
|
661
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
.finding-header {
|
|
665
|
+
padding: 16px 20px;
|
|
666
|
+
cursor: pointer;
|
|
667
|
+
display: flex;
|
|
668
|
+
justify-content: space-between;
|
|
669
|
+
align-items: center;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
.finding-title {
|
|
673
|
+
font-weight: 600;
|
|
674
|
+
font-size: 1.1rem;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
.severity-badge {
|
|
678
|
+
padding: 4px 12px;
|
|
679
|
+
border-radius: 20px;
|
|
680
|
+
font-size: 0.75rem;
|
|
681
|
+
font-weight: 600;
|
|
682
|
+
text-transform: uppercase;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
.finding-content {
|
|
686
|
+
padding: 20px;
|
|
687
|
+
border-top: 1px solid #e5e7eb;
|
|
688
|
+
display: none;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
.finding-content.expanded {
|
|
692
|
+
display: block;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
.finding-content h4 {
|
|
696
|
+
margin-bottom: 8px;
|
|
697
|
+
color: #374151;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
.finding-content p, .finding-content ul {
|
|
701
|
+
margin-bottom: 16px;
|
|
702
|
+
color: #4b5563;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
.remediation {
|
|
706
|
+
background: #f0fdf4;
|
|
707
|
+
border-left: 4px solid #16a34a;
|
|
708
|
+
padding: 16px;
|
|
709
|
+
border-radius: 0 8px 8px 0;
|
|
710
|
+
margin-bottom: 16px;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
.footer {
|
|
714
|
+
text-align: center;
|
|
715
|
+
padding: 40px;
|
|
716
|
+
color: #6b7280;
|
|
717
|
+
font-size: 0.875rem;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
@media print {
|
|
721
|
+
.filter-buttons {{ display: none; }}
|
|
722
|
+
.finding-content {{ display: block !important; }}
|
|
723
|
+
}
|
|
724
|
+
"""
|
|
725
|
+
|
|
726
|
+
def _generate_header(self, summary: ScanSummary) -> str:
|
|
727
|
+
"""Generate report header"""
|
|
728
|
+
scan_date = datetime.fromisoformat(summary.scan_date.replace('Z', '+00:00'))
|
|
729
|
+
formatted_date = scan_date.strftime('%Y-%m-%d %H:%M:%S UTC')
|
|
730
|
+
|
|
731
|
+
return f"""
|
|
732
|
+
<div class="header">
|
|
733
|
+
<div class="container">
|
|
734
|
+
<h1>🔒 Security Scan Report</h1>
|
|
735
|
+
<div class="header-meta">
|
|
736
|
+
<p><strong>Target:</strong> {html.escape(summary.target)}</p>
|
|
737
|
+
<p><strong>Scan Date:</strong> {formatted_date}</p>
|
|
738
|
+
<p><strong>Scan ID:</strong> {html.escape(summary.scan_id)}</p>
|
|
739
|
+
<p><strong>Duration:</strong> {summary.duration} seconds</p>
|
|
740
|
+
</div>
|
|
741
|
+
</div>
|
|
742
|
+
</div>
|
|
743
|
+
"""
|
|
744
|
+
|
|
745
|
+
def _generate_summary(self, summary: ScanSummary) -> str:
|
|
746
|
+
"""Generate summary section with cards"""
|
|
747
|
+
|
|
748
|
+
total = summary.critical + summary.high + summary.medium + summary.low + summary.info
|
|
749
|
+
|
|
750
|
+
# Determine risk class
|
|
751
|
+
if summary.risk_score >= 80:
|
|
752
|
+
risk_class = "risk-critical"
|
|
753
|
+
risk_label = "Critical Risk"
|
|
754
|
+
elif summary.risk_score >= 60:
|
|
755
|
+
risk_class = "risk-high"
|
|
756
|
+
risk_label = "High Risk"
|
|
757
|
+
elif summary.risk_score >= 40:
|
|
758
|
+
risk_class = "risk-medium"
|
|
759
|
+
risk_label = "Medium Risk"
|
|
760
|
+
else:
|
|
761
|
+
risk_class = "risk-low"
|
|
762
|
+
risk_label = "Low Risk"
|
|
763
|
+
|
|
764
|
+
return f"""
|
|
765
|
+
<div class="container">
|
|
766
|
+
<div class="summary-cards">
|
|
767
|
+
<div class="summary-card critical">
|
|
768
|
+
<div class="count">{summary.critical}</div>
|
|
769
|
+
<div class="label">Critical</div>
|
|
770
|
+
</div>
|
|
771
|
+
<div class="summary-card high">
|
|
772
|
+
<div class="count">{summary.high}</div>
|
|
773
|
+
<div class="label">High</div>
|
|
774
|
+
</div>
|
|
775
|
+
<div class="summary-card medium">
|
|
776
|
+
<div class="count">{summary.medium}</div>
|
|
777
|
+
<div class="label">Medium</div>
|
|
778
|
+
</div>
|
|
779
|
+
<div class="summary-card low">
|
|
780
|
+
<div class="count">{summary.low}</div>
|
|
781
|
+
<div class="label">Low</div>
|
|
782
|
+
</div>
|
|
783
|
+
<div class="summary-card info">
|
|
784
|
+
<div class="count">{summary.info}</div>
|
|
785
|
+
<div class="label">Info</div>
|
|
786
|
+
</div>
|
|
787
|
+
</div>
|
|
788
|
+
|
|
789
|
+
<div class="risk-score">
|
|
790
|
+
<h2>Risk Assessment</h2>
|
|
791
|
+
<div class="risk-indicator">
|
|
792
|
+
<div class="risk-value {risk_class}">{summary.risk_score}</div>
|
|
793
|
+
<div>
|
|
794
|
+
<h3>{risk_label}</h3>
|
|
795
|
+
<p>Overall risk score based on {total} findings</p>
|
|
796
|
+
</div>
|
|
797
|
+
</div>
|
|
798
|
+
</div>
|
|
799
|
+
</div>
|
|
800
|
+
"""
|
|
801
|
+
|
|
802
|
+
def _generate_findings(self, findings: List[Finding]) -> str:
|
|
803
|
+
"""Generate findings section"""
|
|
804
|
+
|
|
805
|
+
findings_html = []
|
|
806
|
+
for i, finding in enumerate(findings, 1):
|
|
807
|
+
severity = finding.severity.lower()
|
|
808
|
+
colors = self.SEVERITY_COLORS.get(severity, self.SEVERITY_COLORS["info"])
|
|
809
|
+
|
|
810
|
+
finding_html = f"""
|
|
811
|
+
<div class="finding-item" data-severity="{severity}">
|
|
812
|
+
<div class="finding-header" onclick="toggleFinding({i})">
|
|
813
|
+
<div class="finding-title">{html.escape(finding.title)}</div>
|
|
814
|
+
<span class="severity-badge" style="background: {colors['bg']}; color: {colors['text']};">
|
|
815
|
+
{severity.upper()}
|
|
816
|
+
</span>
|
|
817
|
+
</div>
|
|
818
|
+
<div class="finding-content" id="finding-{i}">
|
|
819
|
+
<h4>🔍 Description</h4>
|
|
820
|
+
<p>{html.escape(finding.description)}</p>
|
|
821
|
+
|
|
822
|
+
<h4>📍 Target</h4>
|
|
823
|
+
<p>{html.escape(finding.target)}</p>
|
|
824
|
+
"""
|
|
825
|
+
|
|
826
|
+
if finding.cve_id:
|
|
827
|
+
finding_html += f"""
|
|
828
|
+
<h4>🐛 CVE Reference</h4>
|
|
829
|
+
<p><a href="https://nvd.nist.gov/vuln/detail/{finding.cve_id}" target="_blank">{finding.cve_id}</a></p>
|
|
830
|
+
"""
|
|
831
|
+
|
|
832
|
+
if finding.cvss_score:
|
|
833
|
+
finding_html += f"""
|
|
834
|
+
<h4>📊 CVSS Score</h4>
|
|
835
|
+
<p>{finding.cvss_score}</p>
|
|
836
|
+
"""
|
|
837
|
+
|
|
838
|
+
if finding.remediation:
|
|
839
|
+
finding_html += f"""
|
|
840
|
+
<h4>🛠️ Remediation</h4>
|
|
841
|
+
<div class="remediation">
|
|
842
|
+
<p>{html.escape(finding.remediation)}</p>
|
|
843
|
+
</div>
|
|
844
|
+
"""
|
|
845
|
+
|
|
846
|
+
if finding.references:
|
|
847
|
+
finding_html += """
|
|
848
|
+
<h4>📚 References</h4>
|
|
849
|
+
<ul>
|
|
850
|
+
"""
|
|
851
|
+
for ref in finding.references:
|
|
852
|
+
finding_html += f'<li><a href="{html.escape(ref)}" target="_blank">{html.escape(ref)}</a></li>'
|
|
853
|
+
finding_html += "</ul>"
|
|
854
|
+
|
|
855
|
+
finding_html += f"""
|
|
856
|
+
<p style="margin-top: 20px; font-size: 0.875rem; color: #6b7280;">
|
|
857
|
+
Finding ID: {finding.id} | Category: {finding.category}
|
|
858
|
+
</p>
|
|
859
|
+
</div>
|
|
860
|
+
</div>
|
|
861
|
+
"""
|
|
862
|
+
|
|
863
|
+
findings_html.append(finding_html)
|
|
864
|
+
|
|
865
|
+
return f"""
|
|
866
|
+
<div class="container">
|
|
867
|
+
<div class="findings-section">
|
|
868
|
+
<div class="findings-header">
|
|
869
|
+
<h2>Detailed Findings ({len(findings)})</h2>
|
|
870
|
+
<div class="filter-buttons">
|
|
871
|
+
<button class="filter-btn active" onclick="filterFindings('all')">All</button>
|
|
872
|
+
<button class="filter-btn" onclick="filterFindings('critical')">Critical</button>
|
|
873
|
+
<button class="filter-btn" onclick="filterFindings('high')">High</button>
|
|
874
|
+
<button class="filter-btn" onclick="filterFindings('medium')">Medium</button>
|
|
875
|
+
<button class="filter-btn" onclick="filterFindings('low')">Low</button>
|
|
876
|
+
</div>
|
|
877
|
+
</div>
|
|
878
|
+
{''.join(findings_html)}
|
|
879
|
+
</div>
|
|
880
|
+
</div>
|
|
881
|
+
"""
|
|
882
|
+
|
|
883
|
+
def _generate_footer(self, summary: ScanSummary) -> str:
|
|
884
|
+
"""Generate report footer"""
|
|
885
|
+
return f"""
|
|
886
|
+
<div class="footer">
|
|
887
|
+
<p>Generated by {self.tool_name} v{self.tool_version}</p>
|
|
888
|
+
<p>This report contains sensitive security information. Handle with care.</p>
|
|
889
|
+
</div>
|
|
890
|
+
"""
|
|
891
|
+
|
|
892
|
+
def _get_javascript(self) -> str:
|
|
893
|
+
"""Get JavaScript for interactivity"""
|
|
894
|
+
return """
|
|
895
|
+
function toggleFinding(id) {
|
|
896
|
+
const content = document.getElementById('finding-' + id);
|
|
897
|
+
content.classList.toggle('expanded');
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function filterFindings(severity) {
|
|
901
|
+
const items = document.querySelectorAll('.finding-item');
|
|
902
|
+
const buttons = document.querySelectorAll('.filter-btn');
|
|
903
|
+
|
|
904
|
+
// Update active button
|
|
905
|
+
buttons.forEach(btn => {
|
|
906
|
+
btn.classList.remove('active');
|
|
907
|
+
if (btn.textContent.toLowerCase() === severity ||
|
|
908
|
+
(severity === 'all' && btn.textContent === 'All')) {
|
|
909
|
+
btn.classList.add('active');
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
// Filter items
|
|
914
|
+
items.forEach(item => {
|
|
915
|
+
if (severity === 'all' || item.dataset.severity === severity) {
|
|
916
|
+
item.style.display = 'block';
|
|
917
|
+
} else {
|
|
918
|
+
item.style.display = 'none';
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
"""
|
|
923
|
+
|
|
924
|
+
def write(self, findings: List[Finding], summary: ScanSummary,
|
|
925
|
+
output_path: Union[str, Path]) -> Path:
|
|
926
|
+
"""Write HTML report to file"""
|
|
927
|
+
output_path = Path(output_path)
|
|
928
|
+
html_content = self.format(findings, summary)
|
|
929
|
+
|
|
930
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
931
|
+
f.write(html_content)
|
|
932
|
+
|
|
933
|
+
return output_path
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
# =============================================================================
|
|
937
|
+
# Utility Functions
|
|
938
|
+
# =============================================================================
|
|
939
|
+
|
|
940
|
+
def convert_findings(raw_findings: List[Dict[str, Any]]) -> List[Finding]:
|
|
941
|
+
"""Convert raw dictionary findings to Finding objects"""
|
|
942
|
+
findings = []
|
|
943
|
+
for f in raw_findings:
|
|
944
|
+
finding = Finding(
|
|
945
|
+
id=f.get('id', str(hashlib.md5(str(f).encode()).hexdigest()[:8])),
|
|
946
|
+
title=f.get('title', 'Unknown'),
|
|
947
|
+
description=f.get('description', ''),
|
|
948
|
+
severity=f.get('severity', 'info').lower(),
|
|
949
|
+
target=f.get('target', ''),
|
|
950
|
+
category=f.get('category', 'general'),
|
|
951
|
+
cve_id=f.get('cve_id'),
|
|
952
|
+
cvss_score=f.get('cvss_score'),
|
|
953
|
+
remediation=f.get('remediation'),
|
|
954
|
+
references=f.get('references', []),
|
|
955
|
+
discovered_at=f.get('discovered_at', datetime.utcnow().isoformat()),
|
|
956
|
+
evidence=f.get('evidence'),
|
|
957
|
+
location=f.get('location')
|
|
958
|
+
)
|
|
959
|
+
findings.append(finding)
|
|
960
|
+
return findings
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def create_summary(findings: List[Finding], scan_id: str, target: str,
|
|
964
|
+
duration: int = 0) -> ScanSummary:
|
|
965
|
+
"""Create scan summary from findings"""
|
|
966
|
+
|
|
967
|
+
counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
|
968
|
+
for f in findings:
|
|
969
|
+
sev = f.severity.lower()
|
|
970
|
+
if sev in counts:
|
|
971
|
+
counts[sev] += 1
|
|
972
|
+
|
|
973
|
+
# Calculate risk score (simple algorithm)
|
|
974
|
+
risk_score = min(100, (
|
|
975
|
+
counts["critical"] * 20 +
|
|
976
|
+
counts["high"] * 10 +
|
|
977
|
+
counts["medium"] * 5 +
|
|
978
|
+
counts["low"] * 1
|
|
979
|
+
))
|
|
980
|
+
|
|
981
|
+
return ScanSummary(
|
|
982
|
+
scan_id=scan_id,
|
|
983
|
+
target=target,
|
|
984
|
+
scan_date=datetime.utcnow().isoformat() + "Z",
|
|
985
|
+
duration=duration,
|
|
986
|
+
total_findings=len(findings),
|
|
987
|
+
critical=counts["critical"],
|
|
988
|
+
high=counts["high"],
|
|
989
|
+
medium=counts["medium"],
|
|
990
|
+
low=counts["low"],
|
|
991
|
+
info=counts["info"],
|
|
992
|
+
risk_score=risk_score
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def export_all_formats(findings: List[Finding], summary: ScanSummary,
|
|
997
|
+
output_dir: Union[str, Path],
|
|
998
|
+
prefix: str = "scan") -> Dict[str, Path]:
|
|
999
|
+
"""Export findings to all supported formats"""
|
|
1000
|
+
|
|
1001
|
+
output_dir = Path(output_dir)
|
|
1002
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
1003
|
+
|
|
1004
|
+
results = {}
|
|
1005
|
+
|
|
1006
|
+
# SARIF
|
|
1007
|
+
sarif_formatter = SARIFFormatter()
|
|
1008
|
+
results['sarif'] = sarif_formatter.write(
|
|
1009
|
+
findings, summary, output_dir / f"{prefix}.sarif"
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
# JUnit XML
|
|
1013
|
+
junit_formatter = JUnitXMLFormatter()
|
|
1014
|
+
results['junit'] = junit_formatter.write(
|
|
1015
|
+
findings, summary, output_dir / f"{prefix}.xml"
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
# HTML
|
|
1019
|
+
html_formatter = HTMLFormatter()
|
|
1020
|
+
results['html'] = html_formatter.write(
|
|
1021
|
+
findings, summary, output_dir / f"{prefix}.html"
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
# JSON
|
|
1025
|
+
json_path = output_dir / f"{prefix}.json"
|
|
1026
|
+
with open(json_path, 'w') as f:
|
|
1027
|
+
json.dump({
|
|
1028
|
+
'summary': asdict(summary),
|
|
1029
|
+
'findings': [asdict(f) for f in findings]
|
|
1030
|
+
}, f, indent=2, default=str)
|
|
1031
|
+
results['json'] = json_path
|
|
1032
|
+
|
|
1033
|
+
return results
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
# =============================================================================
|
|
1037
|
+
# CLI Interface
|
|
1038
|
+
# =============================================================================
|
|
1039
|
+
|
|
1040
|
+
if __name__ == "__main__":
|
|
1041
|
+
import sys
|
|
1042
|
+
|
|
1043
|
+
# Example usage
|
|
1044
|
+
example_findings = [
|
|
1045
|
+
Finding(
|
|
1046
|
+
id="FIND-001",
|
|
1047
|
+
title="SQL Injection Vulnerability",
|
|
1048
|
+
description="The application is vulnerable to SQL injection attacks through the login form.",
|
|
1049
|
+
severity="critical",
|
|
1050
|
+
target="https://example.com/login",
|
|
1051
|
+
category="injection",
|
|
1052
|
+
cve_id="CVE-2023-1234",
|
|
1053
|
+
cvss_score=9.8,
|
|
1054
|
+
remediation="Use parameterized queries and input validation.",
|
|
1055
|
+
references=["https://owasp.org/www-community/attacks/SQL_Injection"]
|
|
1056
|
+
),
|
|
1057
|
+
Finding(
|
|
1058
|
+
id="FIND-002",
|
|
1059
|
+
title="Missing Security Headers",
|
|
1060
|
+
description="The application is missing important security headers.",
|
|
1061
|
+
severity="medium",
|
|
1062
|
+
target="https://example.com",
|
|
1063
|
+
category="configuration",
|
|
1064
|
+
remediation="Add X-Frame-Options, CSP, and HSTS headers."
|
|
1065
|
+
),
|
|
1066
|
+
Finding(
|
|
1067
|
+
id="FIND-003",
|
|
1068
|
+
title="Information Disclosure",
|
|
1069
|
+
description="Server version is exposed in HTTP headers.",
|
|
1070
|
+
severity="low",
|
|
1071
|
+
target="https://example.com",
|
|
1072
|
+
category="information-disclosure",
|
|
1073
|
+
remediation="Configure server to hide version information."
|
|
1074
|
+
)
|
|
1075
|
+
]
|
|
1076
|
+
|
|
1077
|
+
summary = create_summary(example_findings, "scan-001", "https://example.com", 120)
|
|
1078
|
+
|
|
1079
|
+
if len(sys.argv) > 1:
|
|
1080
|
+
output_dir = sys.argv[1]
|
|
1081
|
+
else:
|
|
1082
|
+
output_dir = "./output"
|
|
1083
|
+
|
|
1084
|
+
results = export_all_formats(example_findings, summary, output_dir)
|
|
1085
|
+
|
|
1086
|
+
print("Exported to:")
|
|
1087
|
+
for fmt, path in results.items():
|
|
1088
|
+
print(f" {fmt}: {path}")
|