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.
@@ -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}")