diff --git a/frontend/src/pages/Findings.tsx b/frontend/src/pages/Findings.tsx index b24d5984f..432ef9cb8 100644 --- a/frontend/src/pages/Findings.tsx +++ b/frontend/src/pages/Findings.tsx @@ -5,6 +5,7 @@ import { getFindings } from '../api' import { formatLocaleDate, parseDateSafe, getCurrentTimeZone } from '../utils/date' import SavedViewsPanel from '../components/SavedViewsPanel' import { useSavedViews, FilterPreset } from '../hooks/useSavedViews' +import { exportFindingsAsCSV, exportFindingsAsJSON } from '../utils/exportUtils' type RiskFactor = { factor: string @@ -162,6 +163,10 @@ export default function Findings() { const [reviewState, setReviewState] = useState({}) const [copiedFindingId, setCopiedFindingId] = useState(null) + // ── Multi-select export state & handlers ─────────────────────────────────── + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [exportDropdownOpen, setExportDropdownOpen] = useState(false) + // ── Saved views ──────────────────────────────────────────────────────────── const { views, loading: viewsLoading, saveView, deleteView, renameView } = useSavedViews() @@ -323,6 +328,51 @@ export default function Findings() { }) }, [enrichedFindings, filterSeverity, filterTarget, filterScanner, filterAsset, filterKind, filterAnalystStatus, filterValidatedOnly, filterHighConfidence, searchQuery, dateFrom, dateTo]) + // ── Multi-select export state & handlers ─────────────────────────────────── + const visibleIds = useMemo(() => filteredFindings.map((f) => f.id), [filteredFindings]) + const isAllSelected = useMemo(() => { + if (visibleIds.length === 0) return false + return visibleIds.every((id) => selectedIds.has(id)) + }, [visibleIds, selectedIds]) + + const handleSelectAllToggle = () => { + if (isAllSelected) { + setSelectedIds((prev) => { + const next = new Set(prev) + visibleIds.forEach((id) => next.delete(id)) + return next + }) + } else { + setSelectedIds((prev) => { + const next = new Set(prev) + visibleIds.forEach((id) => next.add(id)) + return next + }) + } + } + + const handleCheckboxChange = (id: string, checked: boolean) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (checked) { + next.add(id) + } else { + next.delete(id) + } + return next + }) + } + + const handleExportCSV = () => { + const selectedFindings = findings.filter((f) => selectedIds.has(f.id)) + exportFindingsAsCSV(selectedFindings) + } + + const handleExportJSON = () => { + const selectedFindings = findings.filter((f) => selectedIds.has(f.id)) + exportFindingsAsJSON(selectedFindings) + } + const sortedFindings = useMemo(() => { const items = [...filteredFindings] switch (sortMode) { @@ -446,6 +496,7 @@ export default function Findings() { setDateFrom('') setDateTo('') setSearchQuery('') + setSelectedIds(new Set()) } function updateFindingStatus(id: string, status: FindingStatus) { @@ -801,15 +852,78 @@ export default function Findings() {

Adjust filters to reopen the queue.

) : ( -
+ <> + {/* Selection & Export Toolbar */} +
+
+ + + {selectedIds.size > 0 && ( + + {selectedIds.size} Selected + + )} +
+ + {selectedIds.size > 0 && ( +
+ + {exportDropdownOpen && ( +
+ + +
+ )} +
+ )} +
+ +
{/* Virtualizer inner container */}
setSelectedFindingId(finding.id)} - className={`relative block w-full px-5 py-5 text-left transition-all ${ + className={`relative flex items-stretch w-full transition-all ${ !isLastInGroup ? 'border-b border-silver-bright/6' : '' } ${isSelected ? 'bg-silver-bright/6' : 'hover:bg-silver-bright/3'}`} > - -
-
-
- - {config.label} - - - {finding.status} - - - {finding.category || 'Uncategorized'} - - {finding.finding_kind ? ( - - {finding.finding_kind.replace('_', ' ')} + {/* Checkbox column */} +
+ handleCheckboxChange(finding.id, e.target.checked)} + className="h-4 w-4 accent-[var(--accent-rag-red)] cursor-pointer" + /> +
+ + {/* Details button */} +
- + +
) })() )} @@ -937,19 +1067,20 @@ export default function Findings() { })}
- )} - {!loading && findings.length < totalItems && ( -
- -
- )} + {!loading && findings.length < totalItems && ( +
+ +
+ )} + + )} {/* ── Detail Panel (unchanged) ── */} diff --git a/frontend/src/utils/exportUtils.ts b/frontend/src/utils/exportUtils.ts new file mode 100644 index 000000000..562c99ca8 --- /dev/null +++ b/frontend/src/utils/exportUtils.ts @@ -0,0 +1,73 @@ +export function escapeCSV(val: any): string { + if (val === null || val === undefined) return '' + const str = String(val) + if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { + return `"${str.replace(/"/g, '""')}"` + } + return str +} + +export function serializeFindingsToCSV(findings: any[]): string { + const headers = [ + 'ID', + 'Title', + 'Severity', + 'Category', + 'Target', + 'Discovered At', + 'CVSS', + 'CVE', + 'Risk Score', + 'Confidence', + 'Validated', + 'Analyst Status', + 'Description', + 'Remediation' + ] + + const rows = findings.map((f) => [ + f.id || '', + f.title || '', + f.severity || '', + f.category || '', + f.target || '', + f.discovered_at || '', + f.cvss !== undefined && f.cvss !== null ? String(f.cvss) : '', + f.cve || '', + f.risk_score !== undefined && f.risk_score !== null ? String(f.risk_score) : '', + f.confidence !== undefined && f.confidence !== null ? String(f.confidence) : '', + f.validated ? 'true' : 'false', + f.analyst_status || '', + f.description || '', + f.remediation || '' + ]) + + return [ + headers.join(','), + ...rows.map((row) => row.map(escapeCSV).join(',')) + ].join('\n') +} + +export function downloadFile(content: string, filename: string, contentType: string): void { + const blob = new Blob([content], { type: contentType }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} + +export function exportFindingsAsCSV(findings: any[]): void { + const csvContent = serializeFindingsToCSV(findings) + const dateStr = new Date().toISOString().split('T')[0] + downloadFile(csvContent, `secuscan_findings_${dateStr}.csv`, 'text/csv;charset=utf-8;') +} + +export function exportFindingsAsJSON(findings: any[]): void { + const jsonContent = JSON.stringify(findings, null, 2) + const dateStr = new Date().toISOString().split('T')[0] + downloadFile(jsonContent, `secuscan_findings_${dateStr}.json`, 'application/json') +} diff --git a/frontend/testing/unit/pages/Findings.test.tsx b/frontend/testing/unit/pages/Findings.test.tsx index 5a23d8c85..c7787ade4 100644 --- a/frontend/testing/unit/pages/Findings.test.tsx +++ b/frontend/testing/unit/pages/Findings.test.tsx @@ -10,6 +10,13 @@ vi.mock('../../../src/api', () => ({ getFindings: vi.fn(), })) +vi.mock('../../../src/utils/exportUtils', () => ({ + exportFindingsAsCSV: vi.fn(), + exportFindingsAsJSON: vi.fn(), +})) + +import { exportFindingsAsCSV, exportFindingsAsJSON } from '../../../src/utils/exportUtils' + vi.mock('../../../src/utils/date', async (importOriginal: any) => { const actual = await importOriginal() as typeof import('../../../src/utils/date') return { @@ -259,4 +266,75 @@ describe('Findings — virtualized list', () => { const suppressedChips = screen.queryAllByText('suppressed') expect(suppressedChips.length).toBeGreaterThan(0) }) + + it('individual checkbox click selects finding for export but doesn\'t change selected finding details', async () => { + const findings = [ + makeFinding({ id: 'f1', title: 'SQL Injection', severity: 'critical' }), + makeFinding({ id: 'f2', title: 'CSRF Vulnerability', severity: 'high' }), + ] + vi.mocked(getFindings).mockResolvedValue({ findings }) + + render() + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) + + const checkboxF2 = screen.getByLabelText('Select CSRF Vulnerability') + expect(checkboxF2).not.toBeChecked() + + await userEvent.click(checkboxF2) + expect(checkboxF2).toBeChecked() + + expect(screen.getByRole('button', { name: /Bulk Export/i })).toBeInTheDocument() + + expect(screen.getByRole('heading', { name: /SQL Injection/i, level: 2 })).toBeInTheDocument() + }) + + it('select all checkbox toggles selection for all visible findings', async () => { + const findings = [ + makeFinding({ id: 'f1', title: 'SQL Injection', severity: 'critical' }), + makeFinding({ id: 'f2', title: 'CSRF Vulnerability', severity: 'high' }), + ] + vi.mocked(getFindings).mockResolvedValue({ findings }) + + render() + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) + + const selectAllCheckbox = screen.getByLabelText(/Select All Visible/i) + await userEvent.click(selectAllCheckbox) + + expect(screen.getByLabelText('Select SQL Injection')).toBeChecked() + expect(screen.getByLabelText('Select CSRF Vulnerability')).toBeChecked() + + await userEvent.click(selectAllCheckbox) + expect(screen.getByLabelText('Select SQL Injection')).not.toBeChecked() + expect(screen.getByLabelText('Select CSRF Vulnerability')).not.toBeChecked() + }) + + it('trigger CSV and JSON bulk export calls utility function', async () => { + const findings = [ + makeFinding({ id: 'f1', title: 'SQL Injection', severity: 'critical' }), + ] + vi.mocked(getFindings).mockResolvedValue({ findings }) + + render() + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) + + await userEvent.click(screen.getByLabelText('Select SQL Injection')) + + const bulkExportBtn = screen.getByRole('button', { name: /Bulk Export/i }) + await userEvent.click(bulkExportBtn) + + const csvExportBtn = screen.getByRole('button', { name: /Export as CSV/i }) + const jsonExportBtn = screen.getByRole('button', { name: /Export as JSON/i }) + + expect(csvExportBtn).toBeInTheDocument() + expect(jsonExportBtn).toBeInTheDocument() + + await userEvent.click(csvExportBtn) + expect(exportFindingsAsCSV).toHaveBeenCalled() + + await userEvent.click(bulkExportBtn) + const newJsonExportBtn = await screen.findByRole('button', { name: /Export as JSON/i }) + await userEvent.click(newJsonExportBtn) + expect(exportFindingsAsJSON).toHaveBeenCalled() + }) }) 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() }) diff --git a/frontend/testing/unit/utils/exportUtils.test.ts b/frontend/testing/unit/utils/exportUtils.test.ts new file mode 100644 index 000000000..3a2eae1bf --- /dev/null +++ b/frontend/testing/unit/utils/exportUtils.test.ts @@ -0,0 +1,66 @@ +import { describe, test, expect } from "vitest"; +import { escapeCSV, serializeFindingsToCSV } from "../../../src/utils/exportUtils"; + +describe("exportUtils utility", () => { + test("escapeCSV handles standard inputs", () => { + expect(escapeCSV("hello")).toBe("hello"); + expect(escapeCSV(123)).toBe("123"); + expect(escapeCSV(null)).toBe(""); + expect(escapeCSV(undefined)).toBe(""); + }); + + test("escapeCSV escapes quotes, commas, and newlines", () => { + expect(escapeCSV('hello, world')).toBe('"hello, world"'); + expect(escapeCSV('hello "world"')).toBe('"hello ""world"""'); + expect(escapeCSV('hello\nworld')).toBe('"hello\nworld"'); + }); + + test("serializeFindingsToCSV generates correct headers and mapped rows", () => { + const sampleFindings = [ + { + id: "f-1", + title: "SQL Injection", + severity: "critical", + category: "Database", + target: "http://target1.local", + discovered_at: "2026-05-12T10:30:00Z", + cvss: 9.8, + cve: "CVE-2026-1234", + risk_score: 9.5, + confidence: 0.9, + validated: true, + analyst_status: "confirmed", + description: "An injection vulnerability in input parameter.", + remediation: "Use parameterized queries." + }, + { + id: "f-2", + title: "Information Disclosure, Version Leak", + severity: "info", + category: "Information", + target: "http://target2.local", + discovered_at: "2026-05-12T10:35:00Z", + cvss: null, + cve: undefined, + risk_score: 1.0, + confidence: 1.0, + validated: false, + analyst_status: "new", + description: "Version string \"1.2.3\" disclosed.", + remediation: "Disable version banners." + } + ]; + + const csvContent = serializeFindingsToCSV(sampleFindings); + + // Header check + expect(csvContent).toContain("ID,Title,Severity,Category,Target,Discovered At,CVSS,CVE,Risk Score,Confidence,Validated,Analyst Status,Description,Remediation"); + + // Row checks + expect(csvContent).toContain("f-1,SQL Injection,critical,Database,http://target1.local,2026-05-12T10:30:00Z,9.8,CVE-2026-1234,9.5,0.9,true,confirmed,An injection vulnerability in input parameter.,Use parameterized queries."); + + // Check comma escaping in title, quote escaping in description + expect(csvContent).toContain('"Information Disclosure, Version Leak"'); + expect(csvContent).toContain('"Version string ""1.2.3"" disclosed."'); + }); +});