diff --git a/backend/secuscan/reporting.py b/backend/secuscan/reporting.py index f71658f09..56395ce82 100644 --- a/backend/secuscan/reporting.py +++ b/backend/secuscan/reporting.py @@ -96,6 +96,54 @@ def _generate_severity_chart(cls, severity_counts: Dict[str, int]) -> str: 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 _get_ai_summary(cls, findings): """Return an AI executive summary, or '' when the feature is disabled.""" @@ -761,6 +809,26 @@ 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""" +
  • +
    + {cls._escape_html(item['title'])} +
    + {item['priority']} Priority + {item['difficulty']} + Target: {cls._escape_html(item['target'])} +
    +

    Remediation action: {cls._escape_html(item['remediation'])}

    +
    +
  • + """ + if not roadmap_html_markup: + roadmap_html_markup = "
  • No remediation actions needed.
  • " + return f""" @@ -991,6 +1059,72 @@ def generate_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> s }} .remediation p, .remediation h4 {{ color: var(--success-ink); }} .empty-state {{ text-align: center; }} + + /* Roadmap Checklist Styling */ + .roadmap-checklist {{ + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 14px; + }} + .roadmap-item {{ + background: var(--panel); + border: 1px solid var(--line); + border-radius: 18px; + padding: 20px 24px; + box-shadow: 0 4px 16px rgba(15, 23, 42, 0.02); + transition: border-color 0.2s ease, box-shadow 0.2s ease; + }} + .roadmap-item:hover {{ + border-color: #cbd5e1; + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05); + }} + .roadmap-details {{ + display: flex; + flex-direction: column; + gap: 6px; + flex-grow: 1; + }} + .roadmap-title {{ + font-size: 15.5px; + font-weight: 600; + color: var(--ink); + transition: color 0.25s ease; + }} + .roadmap-meta {{ + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + }} + .badge {{ + font-size: 10px; + font-weight: 700; + padding: 3px 10px; + border-radius: 99px; + text-transform: uppercase; + letter-spacing: 0.05em; + }} + .badge.priority-immediate {{ background: rgba(239, 68, 68, 0.1); color: var(--high); }} + .badge.priority-scheduled {{ background: rgba(245, 158, 11, 0.1); color: var(--medium); }} + .badge.priority-backlog {{ background: rgba(100, 116, 139, 0.1); color: var(--subtle); }} + + .badge.difficulty-quick-fix {{ background: rgba(34, 197, 94, 0.1); color: #16a34a; }} + .badge.difficulty-standard-fix {{ background: rgba(37, 99, 235, 0.1); color: var(--low); }} + .badge.difficulty-complex-fix {{ background: rgba(217, 119, 6, 0.1); color: var(--medium); }} + + .roadmap-target {{ + font-size: 12.5px; + color: var(--subtle); + }} + .roadmap-action {{ + font-size: 13px; + color: var(--muted); + margin: 4px 0 0; + }} + @page {{ size: A4; margin: 14mm 12mm 16mm; @@ -1053,7 +1187,7 @@ def generate_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> s

    Key takeaways and severity distribution generated from the parsed assessment data.

    - {f'''

    🤖 AI Executive Summary

    {cls._escape_html(ai_summary)}

    ''' if ai_summary else ""} + {f'''

    🤖 AI Executive Summary

    {cls._escape_html(ai_summary)}

    ''' if ai_summary else ""}
    @@ -1062,6 +1196,14 @@ def generate_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> s
    +
    +

    Remediation Roadmap

    +

    Actionable steps grouped by implementation priority and complexity. Mark off completed items to track your progress.

    + +
    +

    Scan Parameters

    Runtime configuration captured for this task, including the selected Nikto flags and SecuScan preset context.

    diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8dd006c19..6b85eeb53 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2662,9 +2662,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.10", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz", - "integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==", + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", "license": "(MPL-2.0 OR Apache-2.0)", "optional": true, "optionalDependencies": { @@ -4545,9 +4545,9 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { diff --git a/frontend/package.json b/frontend/package.json index 8d0870432..53628851b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -53,7 +53,9 @@ "overrides": { "esbuild": "^0.28.1", "react-router": "^6.30.4", - "dompurify": "^3.4.10", + "dompurify": "^3.4.11", + "undici": "^7.28.0", "@babel/core": "^7.29.7" } } + diff --git a/frontend/src/index.css b/frontend/src/index.css index 31549e3b4..c68655c86 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -3855,3 +3855,127 @@ button, white-space: nowrap; } } + +/* ============================================ + Print / PDF Export Styling + ============================================ */ +@media print { + /* Hide navigation, button toolbars, and dynamic status bars */ + aside, + [role="navigation"], + header .flex-wrap, + header .no-print, + .no-print, + footer, + button, + .btn, + .topbar, + .sidebar, + nav, + .app-shell > aside, + .app-shell > div:first-child, + .material-symbols-outlined { + display: none !important; + } + + /* Reset layout for print */ + body, + html, + .app-shell, + .app-main, + main, + #root, + .min-h-screen, + .flex.flex-col { + background: #ffffff !important; + color: #000000 !important; + width: 100% !important; + margin: 0 !important; + padding: 0 !important; + display: block !important; + overflow: visible !important; + } + + /* Reset typography colors, text shadow, and borders */ + h1, h2, h3, h4, h5, h6, p, span, div, td, th { + color: #000000 !important; + text-shadow: none !important; + box-shadow: none !important; + } + + /* Change grid layout to normal blocks to prevent weird clipping in print pages */ + .grid { + display: block !important; + } + + .grid > * { + margin-bottom: 2rem !important; + page-break-inside: avoid !important; + } + + /* Convert charcoal components to clean white panels with basic borders */ + .bg-charcoal, + .bg-charcoal\/30, + .bg-charcoal-dark, + .bg-charcoal\/80, + .charcoal-gradient, + .bg-black\/20, + div[class*="bg-charcoal"] { + background: #ffffff !important; + border: 1px solid #000000 !important; + color: #000000 !important; + } + + /* Adjust border line colors for layout elements */ + .border, + .border-b, + .border-t, + .border-l, + .border-r, + .border-accent-silver\/5, + .border-silver-bright\/10, + div[class*="border-"] { + border-color: #cccccc !important; + } + + /* Ensure text colors for threats and statuses are readable and bold */ + .text-rag-red { + color: #c00000 !important; + font-weight: bold !important; + } + .text-rag-amber { + color: #d97706 !important; + font-weight: bold !important; + } + .text-rag-green { + color: #16a34a !important; + font-weight: bold !important; + } + .text-rag-blue { + color: #1e40af !important; + font-weight: bold !important; + } + + /* Enable color printing for background progress bars or fills */ + .bg-rag-red, + .bg-rag-amber, + .bg-rag-green, + .bg-rag-blue { + -webkit-print-color-adjust: exact !important; + print-color-adjust: exact !important; + } + + /* Prevent print elements from clipping across pages */ + h1, h2, h3, h4 { + page-break-after: avoid !important; + page-break-inside: avoid !important; + } + + section, + .card, + .border, + .bg-charcoal { + page-break-inside: avoid !important; + } +} + diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 52a26fd65..1426a60be 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -5,6 +5,7 @@ import { getDashboardSummary, getHealth, cancelTask } from '../api' import { ExecutiveStatsBar } from '../components/ExecutiveStatsBar' import { routePath, routes } from '../routes' import { formatBriefingDate, formatTaskInit, formatLocaleDate, formatLocaleTime } from '../utils/date' +import { generateMarkdownReport } from '../utils/reportBuilder' type Finding = { id: string @@ -176,6 +177,7 @@ const itemVariants = { } export default function Dashboard() { + const [showExportDropdown, setShowExportDropdown] = useState(false) const [summary, setSummary] = useState(emptySummary) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -239,6 +241,30 @@ export default function Dashboard() { } } + const handleDownloadMarkdown = () => { + try { + const markdown = generateMarkdownReport(summary, risk.label) + const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.setAttribute('download', `secuscan_report_${new Date().toISOString().split('T')[0]}.md`) + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } catch (err) { + console.error('Failed to generate or download Markdown report:', err) + } + setShowExportDropdown(false) + } + + const handlePrintPDF = () => { + setShowExportDropdown(false) + setTimeout(() => { + window.print() + }, 150) + } + const risk = getRiskProfile(summary) const criticalHigh = summary.critical_findings + summary.high_findings @@ -281,8 +307,58 @@ export default function Dashboard() { initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0.2, duration: 0.8, ease: [0.19, 1, 0.22, 1] }} - className="flex flex-col md:flex-row items-start md:items-center gap-8 md:gap-10" + className="flex flex-col md:flex-row items-start md:items-center gap-6 md:gap-8 no-print" > + {/* Export Report Dropdown Button */} +
    + + + + {showExportDropdown && ( + <> +
    setShowExportDropdown(false)} + /> + + + + + + )} + +
    + {/* Integrity Metric - Live Status Panel */}
    {/* Subtle top glow */} 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' && ( <> + +