diff --git a/backend/secuscan/reporting.py b/backend/secuscan/reporting.py
index 93e98aeff..f71658f09 100644
--- a/backend/secuscan/reporting.py
+++ b/backend/secuscan/reporting.py
@@ -29,6 +29,73 @@ class ReportGenerator:
"INFO": (71, 85, 105),
}
+ @classmethod
+ def _generate_severity_chart(cls, severity_counts: Dict[str, int]) -> str:
+ """Generate a base64 PNG horizontal bar chart representing the vulnerability distribution."""
+ width, height = 480, 160
+ img = Image.new("RGBA", (width, height), (255, 255, 255, 0))
+ draw = ImageDraw.Draw(img)
+
+ colors_map = {
+ "CRITICAL": (153, 27, 27, 255),
+ "HIGH": (220, 38, 38, 255),
+ "MEDIUM": (217, 119, 6, 255),
+ "LOW": (37, 99, 235, 255),
+ "INFO": (71, 85, 105, 255)
+ }
+
+ severities = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]
+ max_val = max(severity_counts.values()) if any(severity_counts.values()) else 1
+
+ # Draw background container
+ draw.rounded_rectangle([0, 0, width - 1, height - 1], radius=8, fill=(248, 250, 252, 255), outline=(226, 232, 240, 255), width=1)
+
+ y_offset = 12
+ bar_height = 16
+ spacing = 10
+ x_start = 110
+ max_bar_width = 280
+
+ from PIL import ImageFont
+ font = None
+ for font_name in ("arial.ttf", "Helvetica.ttf", "segoeui.ttf", "sans-serif.ttf"):
+ try:
+ font = ImageFont.truetype(font_name, 12)
+ break
+ except Exception:
+ continue
+ if font is None:
+ try:
+ font = ImageFont.load_default()
+ except Exception:
+ font = None
+
+ for i, sev in enumerate(severities):
+ count = severity_counts.get(sev, 0)
+ bar_len = int((count / max_val) * max_bar_width) if count > 0 else 0
+ color = colors_map[sev]
+
+ # Draw background progress track
+ draw.rounded_rectangle([x_start, y_offset, x_start + max_bar_width, y_offset + bar_height], radius=4, fill=(226, 232, 240, 255))
+
+ # Draw actual severity bar
+ if count > 0:
+ draw.rounded_rectangle([x_start, y_offset, x_start + bar_len, y_offset + bar_height], radius=4, fill=color)
+
+ # Draw labels
+ if font:
+ # Severity label
+ draw.text((20, y_offset + 2), sev.title(), fill=(71, 85, 105, 255), font=font)
+ # Count label
+ draw.text((x_start + bar_len + 10, y_offset + 2), str(count), fill=(15, 23, 42, 255), font=font)
+
+ y_offset += bar_height + spacing
+
+ output = io.BytesIO()
+ img.save(output, format="PNG")
+ encoded = base64.b64encode(output.getvalue()).decode("ascii")
+ return f"data:image/png;base64,{encoded}"
+
@classmethod
def _get_ai_summary(cls, findings):
"""Return an AI executive summary, or '' when the feature is disabled."""
@@ -647,6 +714,7 @@ def generate_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> s
rows_icon = cls._icon_data_uri("rows", "2563eb")
clock_icon = cls._icon_data_uri("clock", "475569")
target_html = cls._escape_html_with_breaks(payload["target"])
+ severity_chart_data = cls._generate_severity_chart(severity_counts)
summary_markup = "".join(
f"
{cls._escape_html(line)}" for line in payload["summary"]
@@ -657,7 +725,7 @@ def generate_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> s
)
finding_markup = "".join(
f"""
-
+
{cls._escape_html(finding['severity'])}
@@ -846,6 +914,17 @@ def generate_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> s
}}
.finding-card {{
overflow: hidden;
+ border-left: 6px solid var(--info);
+ }}
+ .finding-card.severity-critical {{ border-left-color: var(--critical); }}
+ .finding-card.severity-high {{ border-left-color: var(--high); }}
+ .finding-card.severity-medium {{ border-left-color: var(--medium); }}
+ .finding-card.severity-low {{ border-left-color: var(--low); }}
+ .finding-card.severity-info {{ border-left-color: var(--info); }}
+
+ .finding-card:hover {{
+ transform: translateY(-2px);
+ box-shadow: 0 16px 36px rgba(15, 23, 42, 0.08);
}}
.finding-top {{
display: flex;
@@ -971,9 +1050,16 @@ def generate_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> s
Executive Overview
- Key takeaways generated from the parsed assessment data.
- {f'''🤖 AI Executive Summary
{cls._escape_html(ai_summary)}
''' if ai_summary else ""}
-
+ Key takeaways and severity distribution generated from the parsed assessment data.
+
+
+ {f'''
🤖 AI Executive Summary
{cls._escape_html(ai_summary)}
''' if ai_summary else ""}
+
+
+
+

+
+
diff --git a/testing/backend/unit/test_reporting_charts.py b/testing/backend/unit/test_reporting_charts.py
new file mode 100644
index 000000000..05bfbd970
--- /dev/null
+++ b/testing/backend/unit/test_reporting_charts.py
@@ -0,0 +1,69 @@
+import pytest
+from backend.secuscan.reporting import ReportGenerator
+
+@pytest.fixture
+def sample_task():
+ return {
+ "id": "task-123",
+ "tool_name": "Test Scanner",
+ "plugin_id": "test_plugin",
+ "target": "example.com",
+ "status": "completed",
+ "created_at": "2026-06-17T12:00:00.000000Z",
+ "preset": "Full Scan",
+ "command_used": "test-scanner --target example.com",
+ "inputs": {
+ "depth": "deep"
+ }
+ }
+
+@pytest.fixture
+def sample_result():
+ return {
+ "findings": [
+ {
+ "id": "finding-1",
+ "title": "SQL Injection",
+ "category": "Injection",
+ "severity": "CRITICAL",
+ "target": "example.com/login.php",
+ "description": "A SQL injection vulnerability exists in the login form.",
+ "proof": "UNION SELECT username, password FROM users;",
+ "remediation": "Use parameterized SQL queries and input sanitization.",
+ "validated": True,
+ },
+ {
+ "id": "finding-2",
+ "title": "XSS Vulnerability",
+ "category": "XSS",
+ "severity": "HIGH",
+ "target": "example.com/search.php",
+ "description": "Reflected Cross-Site Scripting via query parameter.",
+ "proof": "",
+ "remediation": "Escape user input in output HTML rendering.",
+ "validated": False,
+ }
+ ],
+ "structured": {
+ "rows": []
+ },
+ "summary": ["Scan completed successfully."],
+ "errors": []
+ }
+
+def test_generate_severity_chart():
+ severity_counts = {
+ "CRITICAL": 1,
+ "HIGH": 2,
+ "MEDIUM": 0,
+ "LOW": 4,
+ "INFO": 0
+ }
+ chart_data = ReportGenerator._generate_severity_chart(severity_counts)
+ assert isinstance(chart_data, str)
+ assert chart_data.startswith("data:image/png;base64,")
+
+def test_html_report_contains_chart(sample_task, sample_result):
+ html_report = ReportGenerator.generate_html_report(sample_task, sample_result)
+ assert "Severity Distribution Chart" in html_report
+ assert "data:image/png;base64," in html_report
diff --git a/testing/backend/unit/test_reporting_core.py b/testing/backend/unit/test_reporting_core.py
new file mode 100644
index 000000000..597f423f7
--- /dev/null
+++ b/testing/backend/unit/test_reporting_core.py
@@ -0,0 +1,137 @@
+import json
+import csv
+import io
+import pytest
+from backend.secuscan.reporting import ReportGenerator
+
+@pytest.fixture
+def sample_task():
+ return {
+ "id": "task-123",
+ "tool_name": "Test Scanner",
+ "plugin_id": "test_plugin",
+ "target": "example.com",
+ "status": "completed",
+ "created_at": "2026-06-17T12:00:00.000000Z",
+ "preset": "Full Scan",
+ "command_used": "test-scanner --target example.com",
+ "inputs": {
+ "depth": "deep",
+ "threads": 4,
+ "enable_ssl": True
+ }
+ }
+
+@pytest.fixture
+def sample_result():
+ return {
+ "findings": [
+ {
+ "id": "finding-1",
+ "title": "SQL Injection",
+ "category": "Injection",
+ "severity": "CRITICAL",
+ "target": "example.com/login.php",
+ "cvss": 9.8,
+ "cve": "CVE-2026-0001",
+ "cwe": "CWE-89",
+ "cpe": "cpe:/a:test:login:1.0",
+ "validated": True,
+ "validation_method": "Exploitation payload sent",
+ "confidence_reason": "Vulnerability confirmed via active database response.",
+ "description": "A SQL injection vulnerability exists in the login form.",
+ "proof": "UNION SELECT username, password FROM users;",
+ "remediation": "Use parameterized SQL queries and input sanitization.",
+ "metadata": {
+ "payload": "' OR 1=1 --"
+ }
+ },
+ {
+ "id": "finding-2",
+ "title": "XSS Vulnerability",
+ "category": "XSS",
+ "severity": "HIGH",
+ "target": "example.com/search.php",
+ "cvss": 7.5,
+ "cve": "",
+ "cwe": "CWE-79",
+ "validated": False,
+ "description": "Reflected Cross-Site Scripting via query parameter.",
+ "proof": "",
+ "remediation": "Escape user input in output HTML rendering."
+ }
+ ],
+ "structured": {
+ "open_ports": [80, 443],
+ "technologies": ["Apache", "PHP"],
+ "rows": [
+ {"port": 80, "service": "http"},
+ {"port": 443, "service": "https"}
+ ]
+ },
+ "summary": [
+ "The scan completed successfully.",
+ "Found two high-severity vulnerabilities."
+ ],
+ "errors": []
+ }
+
+def test_generate_csv_report(sample_task, sample_result):
+ csv_report = ReportGenerator.generate_csv_report(sample_task, sample_result)
+ assert isinstance(csv_report, str)
+
+ # Read CSV
+ f = io.StringIO(csv_report)
+ reader = csv.reader(f)
+ rows = list(reader)
+
+ # Check headers
+ assert len(rows) > 0
+ headers = rows[0]
+ expected_headers = [
+ "Severity", "Title", "Category", "Target", "CVSS", "CVE", "CPE",
+ "Validated", "Validation Method", "Confidence Reason", "Description",
+ "Evidence", "Remediation"
+ ]
+ assert headers == expected_headers
+
+ # Check rows
+ assert len(rows) == 3 # Header + 2 findings
+ assert rows[1][0] == "CRITICAL"
+ assert rows[1][1] == "SQL Injection"
+ assert rows[1][7] == "yes"
+ assert rows[2][0] == "HIGH"
+ assert rows[2][7] == "no"
+
+def test_generate_html_report(sample_task, sample_result):
+ html_report = ReportGenerator.generate_html_report(sample_task, sample_result)
+ assert isinstance(html_report, str)
+ assert "" in html_report
+ assert "SecuScan Report" in html_report
+ assert "SQL Injection" in html_report
+ assert "XSS Vulnerability" in html_report
+ assert "example.com" in html_report
+
+def test_generate_pdf_report(sample_task, sample_result):
+ pdf_report = ReportGenerator.generate_pdf_report(sample_task, sample_result)
+ assert isinstance(pdf_report, bytes)
+ # PDF header signature
+ assert pdf_report.startswith(b"%PDF")
+
+def test_generate_sarif_report(sample_task, sample_result):
+ sarif_report = ReportGenerator.generate_sarif_report(sample_task, sample_result)
+ assert isinstance(sarif_report, str)
+
+ # Parse JSON
+ sarif_data = json.loads(sarif_report)
+ assert sarif_data["version"] == "2.1.0"
+ assert "runs" in sarif_data
+ run = sarif_data["runs"][0]
+ assert run["tool"]["driver"]["name"] == "Test Scanner"
+
+ # Check results
+ results = run["results"]
+ assert len(results) == 2
+ assert results[0]["ruleId"] == "cve-2026-0001" # Derived from CVE
+ assert results[0]["level"] == "error"
+ assert results[1]["ruleId"] == "cwe-79" # Derived from CWE