From b2c04dfd716b860ec59009d86e4f9baa6071f8be Mon Sep 17 00:00:00 2001 From: Rafia minhaj Date: Tue, 16 Jun 2026 12:24:10 +0530 Subject: [PATCH 1/5] feat(reporting): Add frontend scan export buttons and print layout overrides --- frontend/src/index.css | 50 +++++++++++++++++++++++ frontend/src/pages/TaskDetails.tsx | 64 ++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) 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' && ( <> + + + + + {showExportDropdown && ( + <> +
setShowExportDropdown(false)} + /> + + + + + + )} + +
+ {/* Integrity Metric - Live Status Panel */}
{/* Subtle top glow */} diff --git a/frontend/src/utils/reportBuilder.ts b/frontend/src/utils/reportBuilder.ts new file mode 100644 index 000000000..6544dcacf --- /dev/null +++ b/frontend/src/utils/reportBuilder.ts @@ -0,0 +1,107 @@ +import { formatDateLong } from './date' + +export interface Finding { + id: string + severity: string + title: string + target: string + discovered_at: string +} + +export interface Task { + id: string + plugin_id: string + tool_name: string + target: string + status: string + created_at: string + duration_seconds?: number | null +} + +export interface Summary { + total_findings: number + critical_findings: number + high_findings: number + medium_findings: number + low_findings: number + info_findings: number + last_scan_time: string | null + recent_findings: Finding[] + running_tasks: Task[] + recent_tasks: Task[] + scan_activity: { total: number; completed: number; running: number } +} + +export function generateMarkdownReport(summary: Summary, riskLabel: string): string { + const dateStr = new Date().toISOString() + const formattedDate = formatDateLong(dateStr) + + let md = `# SecuScan Security Audit Report\n\n` + md += `**Generated At:** ${formattedDate}\n` + md += `**Overall Threat Level:** ${riskLabel.toUpperCase()}\n\n` + + md += `## 1. Executive Summary & Metrics\n\n` + md += `This section contains a summary of the security vulnerability findings and scanning activity details.\n\n` + md += `| Metric | Count |\n` + md += `| :--- | :--- |\n` + md += `| **Total Findings** | ${summary.total_findings} |\n` + md += `| Critical Risk | ${summary.critical_findings} |\n` + md += `| High Severity | ${summary.high_findings} |\n` + md += `| Medium Alert | ${summary.medium_findings} |\n` + md += `| Low Exposure | ${summary.low_findings} |\n` + md += `| Informational | ${summary.info_findings} |\n\n` + + md += `### Scan Activity Summary\n` + md += `- **Total Tasks Executed:** ${summary.scan_activity.total}\n` + md += `- **Completed Tasks:** ${summary.scan_activity.completed}\n` + md += `- **Active/Running Tasks:** ${summary.scan_activity.running}\n\n` + + md += `## 2. Recent Audit Findings\n\n` + if (summary.recent_findings && summary.recent_findings.length > 0) { + md += `| Title | Severity | Target | Discovered At |\n` + md += `| :--- | :--- | :--- | :--- |\n` + summary.recent_findings.forEach((finding) => { + const discoveredDate = finding.discovered_at ? formatDateLong(finding.discovered_at) : 'N/A' + md += `| ${finding.title} | **${finding.severity.toUpperCase()}** | \`${finding.target}\` | ${discoveredDate} |\n` + }) + md += `\n` + } else { + md += `No recent findings detected.\n\n` + } + + md += `## 3. Recent Scans & Task Activity\n\n` + if (summary.recent_tasks && summary.recent_tasks.length > 0) { + md += `| Tool/Plugin | Target | Status | Created At | Duration |\n` + md += `| :--- | :--- | :--- | :--- | :--- |\n` + summary.recent_tasks.forEach((task) => { + const createdDate = task.created_at ? formatDateLong(task.created_at) : 'N/A' + const duration = task.duration_seconds != null && task.duration_seconds > 0 + ? `${Math.round(task.duration_seconds)}s` + : 'N/A' + const name = task.tool_name || task.plugin_id + md += `| ${name.toUpperCase()} | \`${task.target}\` | ${task.status.toUpperCase()} | ${createdDate} | ${duration} |\n` + }) + md += `\n` + } else { + md += `No recent task activity logged.\n\n` + } + + md += `## 4. Remediation Roadmap & Recommendations\n\n` + const hasCriticalOrHigh = summary.critical_findings > 0 || summary.high_findings > 0 + if (hasCriticalOrHigh) { + md += `### High Priority Action Items\n\n` + if (summary.critical_findings > 0) { + md += `- [ ] **Critical Alert:** Address the ${summary.critical_findings} critical vulnerability/vulnerabilities immediately to prevent host or network compromise.\n` + } + if (summary.high_findings > 0) { + md += `- [ ] **High Alert:** Review and remediate the ${summary.high_findings} high-severity issue(s).\n` + } + md += `- [ ] Implement restrictive access controls and verify input validation across targets.\n` + } else { + md += `No critical or high severity vulnerabilities found. Continue regular scanning cycles to maintain security posture.\n` + } + + md += `\n---\n*Report generated by SecuScan Workspace.*` + + return md +} diff --git a/frontend/testing/unit/utils/reportBuilder.test.ts b/frontend/testing/unit/utils/reportBuilder.test.ts new file mode 100644 index 000000000..af1b6c17f --- /dev/null +++ b/frontend/testing/unit/utils/reportBuilder.test.ts @@ -0,0 +1,105 @@ +import { describe, test, expect } from "vitest"; +import { generateMarkdownReport, Summary } from "../../../src/utils/reportBuilder"; + +describe("reportBuilder utility", () => { + const sampleSummary: Summary = { + total_findings: 5, + critical_findings: 1, + high_findings: 2, + medium_findings: 1, + low_findings: 1, + info_findings: 0, + last_scan_time: "2026-05-12T10:30:00Z", + recent_findings: [ + { + id: "f1", + severity: "critical", + title: "SQL Injection", + target: "http://target1.local", + discovered_at: "2026-05-12T10:30:00Z" + }, + { + id: "f2", + severity: "high", + title: "Cross-Site Scripting", + target: "http://target2.local", + discovered_at: "2026-05-12T10:35:00Z" + } + ], + running_tasks: [], + recent_tasks: [ + { + id: "t1", + plugin_id: "sqlmap", + tool_name: "SQLMap Scanner", + target: "http://target1.local", + status: "completed", + created_at: "2026-05-12T10:29:00Z", + duration_seconds: 45 + } + ], + scan_activity: { + total: 10, + completed: 9, + running: 1 + } + }; + + test("generates correct report structure under threat condition", () => { + const report = generateMarkdownReport(sampleSummary, "Severe"); + + // Header checks + expect(report).toContain("# SecuScan Security Audit Report"); + expect(report).toContain("Overall Threat Level:** SEVERE"); + + // Executive summary checks + expect(report).toContain("| Metric | Count |"); + expect(report).toContain("| Critical Risk | 1 |"); + expect(report).toContain("| High Severity | 2 |"); + + // Scan activity details + expect(report).toContain("**Total Tasks Executed:** 10"); + expect(report).toContain("**Completed Tasks:** 9"); + expect(report).toContain("**Active/Running Tasks:** 1"); + + // Recent findings checks + expect(report).toContain("| Title | Severity | Target | Discovered At |"); + expect(report).toContain("| SQL Injection | **CRITICAL** | `http://target1.local` |"); + + // Task activity checks + expect(report).toContain("| Tool/Plugin | Target | Status | Created At | Duration |"); + expect(report).toContain("| SQLMAP SCANNER | `http://target1.local` | COMPLETED |"); + + // Remediation Roadmap checks + expect(report).toContain("### High Priority Action Items"); + expect(report).toContain("**Critical Alert:** Address the 1 critical vulnerability/vulnerabilities immediately"); + expect(report).toContain("**High Alert:** Review and remediate the 2 high-severity issue(s)"); + }); + + test("handles empty lists gracefully", () => { + const emptySummary: Summary = { + total_findings: 0, + critical_findings: 0, + high_findings: 0, + medium_findings: 0, + low_findings: 0, + info_findings: 0, + last_scan_time: null, + recent_findings: [], + running_tasks: [], + recent_tasks: [], + scan_activity: { + total: 0, + completed: 0, + running: 0 + } + }; + + const report = generateMarkdownReport(emptySummary, "Stable"); + + expect(report).toContain("Overall Threat Level:** STABLE"); + expect(report).toContain("No recent findings detected."); + expect(report).toContain("No recent task activity logged."); + expect(report).toContain("No critical or high severity vulnerabilities found. Continue regular scanning cycles to maintain security posture."); + }); +}); From 2bb91ec9bf31541cf684f0cde6b15db199dcbcb5 Mon Sep 17 00:00:00 2001 From: Rafia minhaj Date: Tue, 23 Jun 2026 13:18:46 +0530 Subject: [PATCH 4/5] test: resolve duplicate PDF button selector collision on Reports archive tests --- .../pages/Reports.preferredFormat.test.tsx | 6 ++--- frontend/testing/unit/pages/Reports.test.tsx | 22 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/frontend/testing/unit/pages/Reports.preferredFormat.test.tsx b/frontend/testing/unit/pages/Reports.preferredFormat.test.tsx index 3dae06386..3e3612e73 100644 --- a/frontend/testing/unit/pages/Reports.preferredFormat.test.tsx +++ b/frontend/testing/unit/pages/Reports.preferredFormat.test.tsx @@ -51,7 +51,7 @@ describe('Reports — preferred export format', () => { const user = userEvent.setup() renderReports() - await user.click(await screen.findByRole('button', { name: /^pdf$/i })) + await user.click(await screen.findByRole('button', { name: /^pdf$/ })) expect(localStorage.getItem('secuscan:preferred-export-format')).toBe('pdf') }) @@ -60,7 +60,7 @@ describe('Reports — preferred export format', () => { const user = userEvent.setup() renderReports() - await user.click(await screen.findByRole('button', { name: /^pdf$/i })) + await user.click(await screen.findByRole('button', { name: /^pdf$/ })) await user.click(screen.getByRole('button', { name: /^csv$/i })) expect(localStorage.getItem('secuscan:preferred-export-format')).toBe('csv') @@ -81,7 +81,7 @@ describe('Reports — preferred export format', () => { await screen.findByRole('button', { name: /^csv$/i }) - const buttons = screen.getAllByRole('button', { name: /^(pdf|html|csv)$/i }) + const buttons = screen.getAllByRole('button', { name: /^(pdf|html|csv)$/ }) expect(buttons[0].textContent?.toLowerCase()).toBe('csv') }) diff --git a/frontend/testing/unit/pages/Reports.test.tsx b/frontend/testing/unit/pages/Reports.test.tsx index 3b3ec2303..7bdf69228 100644 --- a/frontend/testing/unit/pages/Reports.test.tsx +++ b/frontend/testing/unit/pages/Reports.test.tsx @@ -129,15 +129,15 @@ describe('Reports — export buttons on a ready report', () => { it('shows PDF, HTML and CSV buttons for a ready report', async () => { renderReports() - expect(await screen.findByRole('button', { name: /^pdf$/i })).toBeInTheDocument() + expect(await screen.findByRole('button', { name: /^pdf$/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: /^html$/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /^csv$/i })).toBeInTheDocument() }) it('export buttons are enabled for a ready report', async () => { renderReports() - await screen.findByRole('button', { name: /^pdf$/i }) - expect(screen.getByRole('button', { name: /^pdf$/i })).not.toBeDisabled() + await screen.findByRole('button', { name: /^pdf$/ }) + expect(screen.getByRole('button', { name: /^pdf$/ })).not.toBeDisabled() expect(screen.getByRole('button', { name: /^html$/i })).not.toBeDisabled() expect(screen.getByRole('button', { name: /^csv$/i })).not.toBeDisabled() }) @@ -145,7 +145,7 @@ describe('Reports — export buttons on a ready report', () => { it('clicking PDF opens the correct backend URL', async () => { const user = userEvent.setup() renderReports() - await user.click(await screen.findByRole('button', { name: /^pdf$/i })) + await user.click(await screen.findByRole('button', { name: /^pdf$/ })) expect(openSpy).toHaveBeenCalledWith( expect.stringContaining('/task/' + readyReport.task_id + '/report/pdf'), '_blank') }) @@ -169,7 +169,7 @@ describe('Reports — export buttons on a ready report', () => { it('does not use the old placeholder latest-report route', async () => { const user = userEvent.setup() renderReports() - await user.click(await screen.findByRole('button', { name: /^pdf$/i })) + await user.click(await screen.findByRole('button', { name: /^pdf$/ })) expect(openSpy).not.toHaveBeenCalledWith(expect.stringContaining('latest'), expect.anything()) }) }) @@ -218,8 +218,8 @@ describe('Reports — export buttons on a generating report', () => { it('export buttons are disabled when report is generating', async () => { renderReports() - await screen.findByRole('button', { name: /^pdf$/i }) - expect(screen.getByRole('button', { name: /^pdf$/i })).toBeDisabled() + await screen.findByRole('button', { name: /^pdf$/ }) + expect(screen.getByRole('button', { name: /^pdf$/ })).toBeDisabled() expect(screen.getByRole('button', { name: /^html$/i })).toBeDisabled() expect(screen.getByRole('button', { name: /^csv$/i })).toBeDisabled() }) @@ -227,8 +227,8 @@ describe('Reports — export buttons on a generating report', () => { it('clicking a disabled button does not open any URL', async () => { const user = userEvent.setup() renderReports() - await screen.findByRole('button', { name: /^pdf$/i }) - await user.click(screen.getByRole('button', { name: /^pdf$/i })) + await screen.findByRole('button', { name: /^pdf$/ }) + await user.click(screen.getByRole('button', { name: /^pdf$/ })) expect(openSpy).not.toHaveBeenCalled() }) }) @@ -242,8 +242,8 @@ describe('Reports — export buttons on a failed report', () => { it('export buttons are enabled for a failed report since backend supports it', async () => { renderReports() - await screen.findByRole('button', { name: /^pdf$/i }) - expect(screen.getByRole('button', { name: /^pdf$/i })).not.toBeDisabled() + await screen.findByRole('button', { name: /^pdf$/ }) + expect(screen.getByRole('button', { name: /^pdf$/ })).not.toBeDisabled() expect(screen.getByRole('button', { name: /^html$/i })).not.toBeDisabled() expect(screen.getByRole('button', { name: /^csv$/i })).not.toBeDisabled() }) From 112db8039659bedb5d7294763ead1e1968c57126 Mon Sep 17 00:00:00 2001 From: Rafia minhaj Date: Tue, 23 Jun 2026 13:58:08 +0530 Subject: [PATCH 5/5] security(dependency): upgrade undici and dompurify in frontend overrides to fix vulnerabilities --- frontend/package-lock.json | 12 ++++++------ frontend/package.json | 4 +++- 2 files changed, 9 insertions(+), 7 deletions(-) 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" } } +