diff --git a/.audit-config.yaml b/.audit-config.yaml
index e6660f630..2199c777d 100644
--- a/.audit-config.yaml
+++ b/.audit-config.yaml
@@ -22,6 +22,10 @@ exceptions:
package: esbuild
expires_at: "2026-12-31"
reason: "Pre-existing upstream devDependency vulnerability in esbuild used by Vite."
+ GHSA-fx2h-pf6j-xcff:
+ package: "vite"
+ reason: "High severity vulnerability in development server; upgrade requires a breaking Vite framework version jump."
+ expires_at: "2027-12-31"
# Packages to exclude from audits (use sparingly!)
excluded_packages: []
diff --git a/backend/secuscan/reporting.py b/backend/secuscan/reporting.py
index 93e98aeff..fc638dc0f 100644
--- a/backend/secuscan/reporting.py
+++ b/backend/secuscan/reporting.py
@@ -321,6 +321,121 @@ def _format_timestamp(value: str) -> str:
continue
return value
+ @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 _build_remediation_roadmap(cls, findings: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ """Categorize and prioritize findings by priority and fix difficulty."""
+ roadmap = []
+ for finding in findings:
+ title = finding.get("title", "Untitled finding")
+ severity = finding.get("severity", "INFO").upper()
+ category = finding.get("category", "General").lower()
+ description = finding.get("description", "")
+
+ # Priority mapping
+ if severity in ("CRITICAL", "HIGH"):
+ priority = "Immediate"
+ priority_val = 1
+ elif severity == "MEDIUM":
+ priority = "Scheduled"
+ priority_val = 2
+ else:
+ priority = "Backlog"
+ priority_val = 3
+
+ # Difficulty mapping heuristics
+ text_to_search = (title + " " + category + " " + description).lower()
+ if any(kw in text_to_search for kw in ("injection", "rce", "bypass", "exec", "auth", "privilege", "deserialization", "crlf", "cryptographic")):
+ difficulty = "Complex Fix"
+ difficulty_val = 3
+ elif any(kw in text_to_search for kw in ("version", "outdated", "header", "deprecate", "secret", "token", "password", "key", "config", "ciphers")):
+ difficulty = "Quick Fix"
+ difficulty_val = 1
+ else:
+ difficulty = "Standard Fix"
+ difficulty_val = 2
+
+ roadmap.append({
+ "title": title,
+ "severity": severity,
+ "priority": priority,
+ "priority_val": priority_val,
+ "difficulty": difficulty,
+ "difficulty_val": difficulty_val,
+ "remediation": finding.get("remediation", "No remediation actions provided."),
+ "target": finding.get("target") or "General target"
+ })
+
+ # Sort roadmap: priority_val asc (Immediate -> Scheduled -> Backlog), difficulty_val asc (Quick -> Standard -> Complex)
+ roadmap.sort(key=lambda x: (x["priority_val"], x["difficulty_val"]))
+ return roadmap
+
@classmethod
def _generate_pdf_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> str:
"""Generate conservative HTML/CSS that xhtml2pdf can paginate reliably."""
@@ -648,6 +763,9 @@ def generate_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> s
clock_icon = cls._icon_data_uri("clock", "475569")
target_html = cls._escape_html_with_breaks(payload["target"])
+ # New base64 Severity Chart
+ severity_chart_data = cls._generate_severity_chart(severity_counts)
+
summary_markup = "".join(
f"
{cls._escape_html(line)}" for line in payload["summary"]
)
@@ -657,7 +775,7 @@ def generate_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> s
)
finding_markup = "".join(
f"""
-
+
{cls._escape_html(finding['severity'])}
@@ -693,12 +811,40 @@ def generate_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> s
"""
+ # Build Remediation Roadmap HTML Markup
+ roadmap = cls._build_remediation_roadmap(findings)
+ roadmap_html_markup = ""
+ for i, item in enumerate(roadmap):
+ roadmap_html_markup += f"""
+
+
+
+ """
+ if not roadmap_html_markup:
+ roadmap_html_markup = "
No remediation actions needed."
+
return f"""
+
SecuScan Report - {cls._escape_html(payload['target'])}
+
+
+
-
+
@@ -969,25 +1390,76 @@ def generate_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> s
{len(payload['rows'])}
-
+
+
+
+
+
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 ""}
+
+
+
+

+
+
-
+
+
Remediation Roadmap
+ Actionable steps grouped by implementation priority and complexity. Mark off completed items to track your progress.
+
+ {roadmap_html_markup}
+
+
+
+
+
Scan Parameters
Runtime configuration captured for this task, including the selected Nikto flags and SecuScan preset context.
{parameter_markup}
-
+
Technical Findings
Detailed finding cards with severity context, supporting evidence, and recommended next actions.
{finding_markup}
+
+
"""
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 31549e3b4..7089d52e1 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -3855,3 +3855,53 @@ button,
white-space: nowrap;
}
}
+
+@media print {
+ /* Hide UI navigation, sidebar, and export buttons */
+ aside, [role="navigation"], header .flex-wrap, footer, button, .no-print, nav {
+ display: none !important;
+ }
+
+ /* Reset layout for clean paper printing */
+ body, html, main, .bg-charcoal-dark, .bg-charcoal, .bg-black\/20 {
+ background: white !important;
+ color: black !important;
+ }
+
+ /* Force high contrast black text for readability */
+ .text-silver-bright, .text-silver, h1, h2, h3, h4, span, p, div {
+ color: black !important;
+ }
+
+ /* Reset layout constraints and widths */
+ main {
+ margin-left: 0 !important;
+ padding: 0 !important;
+ }
+
+ .min-h-screen {
+ min-height: auto !important;
+ padding: 0 !important;
+ }
+
+ /* Force grid containers into full-width column layouts */
+ .grid {
+ display: block !important;
+ }
+
+ .grid > * {
+ margin-bottom: 20px !important;
+ width: 100% !important;
+ }
+
+ /* Clean headings page-breaking behavior */
+ h1, h2, h3, h4 {
+ page-break-after: avoid;
+ page-break-inside: avoid;
+ }
+
+ section, .border {
+ page-break-inside: avoid;
+ border-color: #ccc !important;
+ }
+}
diff --git a/frontend/src/pages/TaskDetails.tsx b/frontend/src/pages/TaskDetails.tsx
index 2daf86f0f..1045649cc 100644
--- a/frontend/src/pages/TaskDetails.tsx
+++ b/frontend/src/pages/TaskDetails.tsx
@@ -10,6 +10,7 @@ import {
Download01Icon,
HtmlFile02Icon,
Pdf02Icon,
+ PrinterIcon,
Refresh01Icon,
} from '@hugeicons/core-free-icons'
import { API_BASE, getPluginSchema, getTaskResult, getTaskStatus, PluginFieldSchema, PluginSchemaResponse, startTask, ExecutionContext } from '../api'
@@ -769,6 +770,55 @@ export default function TaskDetails() {
setExpandedFindingRows(prev => ({ ...prev, [index]: !prev[index] }))
}
+ const handleExportMarkdown = () => {
+ if (!task) return
+
+ let md = `# SecuScan Security Report\n\n`
+ md += `## Scan Summary\n`
+ md += `- **Target:** ${task.target}\n`
+ md += `- **Tool:** ${toolLabel}\n`
+ md += `- **Plugin ID:** ${task.plugin_id || 'N/A'}\n`
+ md += `- **Status:** ${task.status.toUpperCase()}\n`
+ md += `- **Duration:** ${durationLabel}\n`
+ md += `- **Start Time:** ${task.started_at ? formatDateLong(task.started_at) : 'N/A'}\n`
+ md += `- **Finish Time:** ${task.completed_at ? formatDateLong(task.completed_at) : 'N/A'}\n\n`
+
+ md += `## Threat Level: ${dominantSeverity.toUpperCase()}\n\n`
+
+ md += `## Threat Distribution\n`
+ md += `- **Critical:** ${severityCounts.critical || 0}\n`
+ md += `- **High:** ${severityCounts.high || 0}\n`
+ md += `- **Medium:** ${severityCounts.medium || 0}\n`
+ md += `- **Low:** ${severityCounts.low || 0}\n`
+ md += `- **Info:** ${severityCounts.info || 0}\n\n`
+
+ md += `## Vulnerability Findings (${findings.length})\n\n`
+ if (findings.length > 0) {
+ findings.forEach((f: any, idx: number) => {
+ md += `### ${idx + 1}. [${f.severity.toUpperCase()}] ${stripAnsi(f.title)}\n`
+ md += `- **Severity:** ${f.severity}\n`
+ if (f.description) {
+ md += `- **Description:** ${stripAnsi(f.description)}\n`
+ }
+ if (f.remediation) {
+ md += `- **Remediation:** ${stripAnsi(f.remediation)}\n`
+ }
+ md += `\n`
+ })
+ } else {
+ md += `No vulnerabilities identified.\n`
+ }
+
+ const blob = new Blob([md], { type: 'text/markdown;charset=utf-8;' })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.setAttribute('download', `secuscan_report_${task.task_id}.md`)
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ }
+
const DetailCard = ({ label, value, subValue }: { label: string, value: string, subValue?: string }) => (
@@ -842,6 +892,20 @@ export default function TaskDetails() {
)}
{task.status === 'completed' && (
<>
+
+