Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 90 additions & 4 deletions backend/secuscan/reporting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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"<li>{cls._escape_html(line)}</li>" for line in payload["summary"]
Expand All @@ -657,7 +725,7 @@ def generate_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> s
)
finding_markup = "".join(
f"""
<article class="finding-card">
<article class="finding-card severity-{finding['severity'].lower()}">
<div class="finding-top">
<span class="severity severity-{finding['severity'].lower()}"><img class="mini-icon" src="{critical_icon}" alt=""> {cls._escape_html(finding['severity'])}</span>
<div class="finding-heading">
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -971,9 +1050,16 @@ def generate_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> s

<section class="section">
<h2><img class="section-icon" src="{shield_icon}" alt="">Executive Overview</h2>
<p class="section-copy">Key takeaways generated from the parsed assessment data.</p>
{f'''<div style="margin:0 0 18px;padding:16px 20px;background:#eff6ff;border-left:4px solid #2563eb;border-radius:14px;"><p style="margin:0 0 6px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#1d4ed8;">&#129302; AI Executive Summary</p><p style="margin:0;color:#1e293b;line-height:1.65;">{cls._escape_html(ai_summary)}</p></div>''' if ai_summary else ""}
<ul class="summary-list">{summary_markup}</ul>
<p class="section-copy">Key takeaways and severity distribution generated from the parsed assessment data.</p>
<div class="executive-container" style="display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap;">
<div style="flex: 1; min-width: 300px;">
{f'''<div style="margin:0 0 18px;padding:16px 20px;background:#eff6ff;border-left:4px solid #2563eb;border-radius:14px;"><p style="margin:0 0 6px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#1d4ed8;">&#129302; AI Executive Summary</p><p style="margin:0;color:#1e293b;line-height:1.65;">{cls._escape_html(ai_summary)}</p></div>''' if ai_summary else ""}
<ul class="summary-list">{summary_markup}</ul>
</div>
<div class="chart-container" style="flex: 0 0 400px; max-width: 100%;">
<img src="{severity_chart_data}" alt="Severity Distribution Chart" style="width: 100%; height: auto; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.05);" />
</div>
</div>
</section>

<section class="section">
Expand Down
69 changes: 69 additions & 0 deletions testing/backend/unit/test_reporting_charts.py
Original file line number Diff line number Diff line change
@@ -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": "<script>alert(1)</script>",
"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
137 changes: 137 additions & 0 deletions testing/backend/unit/test_reporting_core.py
Original file line number Diff line number Diff line change
@@ -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": "<script>alert(1)</script>",
"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 "<!DOCTYPE html>" 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
Loading