diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2d327482..d20131e3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import Toolkit from './pages/Toolkit' import ToolConfig from './pages/ToolConfig' import Findings from './pages/Findings' import Reports from './pages/Reports' +import ReportCompare from './pages/ReportCompare' import Settings from './pages/Settings' import Scans from './pages/Scans' import TaskDetails from './pages/TaskDetails' @@ -27,6 +28,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/ReportCompare.tsx b/frontend/src/pages/ReportCompare.tsx new file mode 100644 index 00000000..ffac3142 --- /dev/null +++ b/frontend/src/pages/ReportCompare.tsx @@ -0,0 +1,350 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { motion } from 'framer-motion' +import { HugeiconsIcon } from '@hugeicons/react' +import { + ArrowLeft02Icon, + Analytics02Icon, + Refresh01Icon, +} from '@hugeicons/core-free-icons' +import { getFindings, getReports } from '../api' +import { routes } from '../routes' +import { formatDateLong } from '../utils/date' +import { + compareFindings, + type ComparableFinding, + type ComparedFinding, + type ReportComparisonResult, +} from '../utils/compareFindings' + +type ReportOption = { + id: string + task_id: string + name: string + generated_at: string + findings: number + status: string +} + +function reportHasFindings( + report: ReportOption, + findingsByTask: Record, +): boolean { + const fromApi = Number(report.findings) + if (fromApi > 0) return true + return (findingsByTask[report.task_id]?.length ?? 0) > 0 +} + +/** Compare uses finding diffs; include ready reports and failed scans that still produced findings. */ +function comparableReports( + rows: ReportOption[], + findingsByTask: Record, +): ReportOption[] { + return rows.filter( + (r) => r.status === 'ready' || (r.status === 'failed' && reportHasFindings(r, findingsByTask)), + ) +} + +function reportOptionLabel(report: ReportOption): string { + const statusNote = report.status === 'failed' ? ' (scan failed)' : '' + return `${report.name}${statusNote} — ${formatDateLong(report.generated_at)}` +} + +const severityChip: Record = { + critical: 'bg-rag-red text-black', + high: 'bg-rag-amber text-black', + medium: 'bg-rag-blue text-black', + low: 'bg-charcoal-dark text-silver-bright border border-silver-bright/15', + info: 'bg-charcoal-dark text-silver border border-silver/15', +} + +function toComparableFinding(raw: Record): ComparableFinding | null { + const title = typeof raw.title === 'string' ? raw.title : '' + const target = typeof raw.target === 'string' ? raw.target : '' + const category = typeof raw.category === 'string' ? raw.category : '' + const severity = typeof raw.severity === 'string' ? raw.severity : 'info' + if (!title && !target) return null + return { + id: typeof raw.id === 'string' ? raw.id : undefined, + title: title || 'Untitled finding', + target: target || 'Unknown target', + category: category || 'General', + severity, + description: typeof raw.description === 'string' ? raw.description : undefined, + } +} + +function FindingRow({ item, showBaseline, showComparison }: { + item: ComparedFinding + showBaseline?: boolean + showComparison?: boolean +}) { + const finding = (showComparison ? item.comparison : item.baseline) ?? item.comparison ?? item.baseline + if (!finding) return null + const severity = (finding.severity || 'info').toLowerCase() + const chip = severityChip[severity] ?? severityChip.info + + return ( + + + + {severity} + + {finding.category} + + {finding.title} + {finding.target} + {showBaseline && showComparison && item.baseline && item.comparison && ( + + {item.baseline.severity} → {item.comparison.severity} + + )} + + ) +} + +function CompareSection({ + title, + items, + tone, + showBaseline, + showComparison, +}: { + title: string + items: ComparedFinding[] + tone: string + showBaseline?: boolean + showComparison?: boolean +}) { + return ( + + + {title} + {items.length} + + {items.length === 0 ? ( + None + ) : ( + + {items.map((item) => ( + + ))} + + )} + + ) +} + +export default function ReportCompare() { + const [reports, setReports] = useState([]) + const [findingsByTask, setFindingsByTask] = useState>({}) + const [baselineReportId, setBaselineReportId] = useState('') + const [comparisonReportId, setComparisonReportId] = useState('') + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const loadData = () => { + setLoading(true) + setError(null) + Promise.all([getReports(), getFindings()]) + .then((results) => { + const reportData = results[0] as { reports?: ReportOption[] } + const findingsData = results[1] as { findings?: Record[] } + + const byTask: Record = {} + for (const raw of findingsData.findings || []) { + const row = raw as Record + const finding = toComparableFinding(row) + if (!finding) continue + const taskId = typeof row.task_id === 'string' ? row.task_id : '' + if (taskId) { + if (!byTask[taskId]) byTask[taskId] = [] + byTask[taskId].push(finding) + } + } + setFindingsByTask(byTask) + + const rawReports = (reportData.reports || []).map((r) => ({ + ...r, + findings: Number(r.findings ?? 0), + })) + setReports(comparableReports(rawReports, byTask)) + }) + .catch(() => setError('Failed to load reports or findings')) + .finally(() => setLoading(false)) + } + + useEffect(() => { + loadData() + }, []) + + const baselineReport = reports.find((r) => r.id === baselineReportId) + const comparisonReport = reports.find((r) => r.id === comparisonReportId) + + const comparison: ReportComparisonResult | null = useMemo(() => { + if (!baselineReport || !comparisonReport) return null + if (baselineReport.id === comparisonReport.id) return null + const baselineFindings = findingsByTask[baselineReport.task_id] || [] + const comparisonFindings = findingsByTask[comparisonReport.task_id] || [] + return compareFindings(baselineFindings, comparisonFindings) + }, [baselineReport, comparisonReport, findingsByTask]) + + const sameReportSelected = Boolean( + baselineReportId && comparisonReportId && baselineReportId === comparisonReportId, + ) + + return ( + + + + + + Back to reports + + + Report_Diff v1.0 + + + Compare Reports + + + BASELINE_VS_COMPARISON // NEW_FIXED_UNCHANGED_SEVERITY + + + + + + + + {loading && ( + + Loading comparison data... + + )} + + {!loading && error && ( + + {error} + + )} + + {!loading && !error && ( + <> + + + + Baseline report (older) + + setBaselineReportId(e.target.value)} + className="w-full bg-charcoal border-4 border-black p-4 text-sm font-mono text-silver-bright" + > + Select baseline... + {reports.map((r) => ( + + {reportOptionLabel(r)} + + ))} + + + + + Comparison report (newer) + + setComparisonReportId(e.target.value)} + className="w-full bg-charcoal border-4 border-black p-4 text-sm font-mono text-silver-bright" + > + Select comparison... + {reports.map((r) => ( + + {reportOptionLabel(r)} + + ))} + + + + + {reports.length < 2 && ( + + At least two reports with findings are required to compare. Run scans that finish + with results, then refresh. + + )} + + {sameReportSelected && ( + + Select two different reports to compare. + + )} + + {comparison && ( + + + + + Diff ready + + {baselineReport?.name} → {comparisonReport?.name} + + + + + + + + + + + + )} + + {!comparison && baselineReportId && comparisonReportId && !sameReportSelected && ( + + No findings to compare for the selected reports. + + )} + > + )} + + ) +} diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx index 7464dbde..2ece7d0e 100644 --- a/frontend/src/pages/Reports.tsx +++ b/frontend/src/pages/Reports.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react' import { motion, AnimatePresence } from 'framer-motion' -import { useNavigate } from 'react-router-dom' +import { useNavigate, Link } from 'react-router-dom' +import { routes } from '../routes' import { HugeiconsIcon } from '@hugeicons/react' import { Analytics02Icon, @@ -63,6 +64,29 @@ function ReportIcon({ return } +function normalizeReport(raw: Partial & Record): Report { + const findings = Number(raw.findings ?? 0) + const pages = Number(raw.pages ?? 0) + const assets = Number(raw.assets ?? 0) + const type = raw.type === 'executive' || raw.type === 'compliance' ? raw.type : 'technical' + const status = + raw.status === 'ready' || raw.status === 'generating' || raw.status === 'failed' + ? raw.status + : 'ready' + + return { + id: String(raw.id ?? ''), + task_id: String(raw.task_id ?? ''), + name: String(raw.name ?? 'Untitled report'), + type, + generated_at: String(raw.generated_at ?? ''), + status, + findings: Number.isFinite(findings) ? findings : 0, + assets: Number.isFinite(assets) ? assets : 0, + pages: Number.isFinite(pages) ? pages : 0, + } +} + export default function Reports() { const navigate = useNavigate() const [reports, setReports] = useState([]) @@ -82,7 +106,8 @@ export default function Reports() { setError(null) Promise.all([getReports(), getDashboardSummary()]) .then(([reportData, summaryData]: any) => { - setReports(reportData.reports || []) + const rows = Array.isArray(reportData?.reports) ? reportData.reports : [] + setReports(rows.map((row: Record) => normalizeReport(row))) setSummary(summaryData || {}) }) .catch(() => { @@ -122,6 +147,12 @@ export default function Reports() { + + Compare Reports + { if (!latestReadyReport) return diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 38caa98f..eaa6bcd7 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -5,6 +5,7 @@ export const routes = { findings: '/findings', scans: '/scans', reports: '/reports', + reportsCompare: '/reports/compare', workflows: '/workflows', settings: '/settings', task: '/task/:taskId', diff --git a/frontend/src/utils/compareFindings.ts b/frontend/src/utils/compareFindings.ts new file mode 100644 index 00000000..a54448a7 --- /dev/null +++ b/frontend/src/utils/compareFindings.ts @@ -0,0 +1,84 @@ +export type ComparableFinding = { + id?: string + title: string + target: string + category: string + severity: string + description?: string +} + +export type ComparedFinding = { + fingerprint: string + baseline?: ComparableFinding + comparison?: ComparableFinding +} + +export type ReportComparisonResult = { + newFindings: ComparedFinding[] + fixedFindings: ComparedFinding[] + unchangedFindings: ComparedFinding[] + severityChanged: ComparedFinding[] +} + +export function findingFingerprint(finding: ComparableFinding): string { + const title = (finding.title || '').trim().toLowerCase() + const target = (finding.target || '').trim().toLowerCase() + const category = (finding.category || '').trim().toLowerCase() + return `${title}|${target}|${category}` +} + +function indexFindings(findings: ComparableFinding[]): Map { + const map = new Map() + for (const finding of findings) { + map.set(findingFingerprint(finding), finding) + } + return map +} + +export function compareFindings( + baselineFindings: ComparableFinding[], + comparisonFindings: ComparableFinding[], +): ReportComparisonResult { + const baseline = indexFindings(baselineFindings) + const comparison = indexFindings(comparisonFindings) + + const newFindings: ComparedFinding[] = [] + const fixedFindings: ComparedFinding[] = [] + const unchangedFindings: ComparedFinding[] = [] + const severityChanged: ComparedFinding[] = [] + + for (const [fingerprint, comparisonFinding] of comparison.entries()) { + const baselineFinding = baseline.get(fingerprint) + if (!baselineFinding) { + newFindings.push({ fingerprint, comparison: comparisonFinding }) + continue + } + + const baselineSeverity = (baselineFinding.severity || 'info').toLowerCase() + const comparisonSeverity = (comparisonFinding.severity || 'info').toLowerCase() + const entry: ComparedFinding = { + fingerprint, + baseline: baselineFinding, + comparison: comparisonFinding, + } + + if (baselineSeverity === comparisonSeverity) { + unchangedFindings.push(entry) + } else { + severityChanged.push(entry) + } + } + + for (const [fingerprint, baselineFinding] of baseline.entries()) { + if (!comparison.has(fingerprint)) { + fixedFindings.push({ fingerprint, baseline: baselineFinding }) + } + } + + return { + newFindings, + fixedFindings, + unchangedFindings, + severityChanged, + } +} diff --git a/frontend/testing/unit/pages/ReportCompare.test.tsx b/frontend/testing/unit/pages/ReportCompare.test.tsx new file mode 100644 index 00000000..d5de326a --- /dev/null +++ b/frontend/testing/unit/pages/ReportCompare.test.tsx @@ -0,0 +1,111 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import ReportCompare from '../../../src/pages/ReportCompare' +import { getFindings, getReports } from '../../../src/api' + +vi.mock('../../../src/api', () => ({ + getReports: vi.fn(), + getFindings: vi.fn(), +})) + +const readyA = { + id: 'report-a', + task_id: 'task-a', + name: 'Scan A', + type: 'technical', + generated_at: '2026-05-01T10:00:00Z', + status: 'ready', + findings: 1, + assets: 1, + pages: 1, +} + +const readyB = { + id: 'report-b', + task_id: 'task-b', + name: 'Scan B', + type: 'technical', + generated_at: '2026-05-02T10:00:00Z', + status: 'ready', + findings: 2, + assets: 1, + pages: 1, +} + +function renderCompare() { + return render( + + + , + ) +} + +describe('ReportCompare page', () => { + beforeEach(() => { + vi.mocked(getReports).mockResolvedValue({ reports: [readyA, readyB] }) + vi.mocked(getFindings).mockResolvedValue({ + findings: [ + { + id: 'f1', + task_id: 'task-a', + title: 'Only in A', + target: '127.0.0.1', + category: 'network', + severity: 'high', + }, + { + id: 'f2', + task_id: 'task-b', + title: 'Only in A', + target: '127.0.0.1', + category: 'network', + severity: 'high', + }, + { + id: 'f3', + task_id: 'task-b', + title: 'Only in B', + target: '127.0.0.1', + category: 'network', + severity: 'critical', + }, + ], + }) + }) + + it('lists failed reports when they still have findings', async () => { + vi.mocked(getReports).mockResolvedValue({ + reports: [ + { ...readyA, status: 'failed' }, + { ...readyB, status: 'failed' }, + ], + }) + renderCompare() + + await waitFor(() => { + const options = screen.getAllByRole('option') + expect(options.some((o) => o.textContent?.includes('Scan A'))).toBe(true) + expect(options.some((o) => o.textContent?.includes('Scan B'))).toBe(true) + }) + }) + + it('renders compare selectors and diff sections', async () => { + const user = userEvent.setup() + renderCompare() + + expect(await screen.findByRole('heading', { name: /compare reports/i })).toBeInTheDocument() + + const selects = screen.getAllByRole('combobox') + await user.selectOptions(selects[0], 'report-a') + await user.selectOptions(selects[1], 'report-b') + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /new findings/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /fixed findings/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /^unchanged$/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /severity changed/i })).toBeInTheDocument() + expect(screen.getByText(/Only in B/i)).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/testing/unit/utils/compareFindings.test.ts b/frontend/testing/unit/utils/compareFindings.test.ts new file mode 100644 index 00000000..0b1f3488 --- /dev/null +++ b/frontend/testing/unit/utils/compareFindings.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { + compareFindings, + findingFingerprint, + type ComparableFinding, +} from '../../../src/utils/compareFindings' + +const base = (overrides: Partial = {}): ComparableFinding => ({ + title: 'Open port', + target: '127.0.0.1', + category: 'network', + severity: 'high', + ...overrides, +}) + +describe('findingFingerprint', () => { + it('is stable for same title target category', () => { + const a = base({ title: 'Open Port', target: '127.0.0.1' }) + const b = base({ title: 'open port', target: '127.0.0.1' }) + expect(findingFingerprint(a)).toBe(findingFingerprint(b)) + }) +}) + +describe('compareFindings', () => { + it('detects new findings in comparison scan', () => { + const baseline = [base({ title: 'Old issue' })] + const comparison = [base({ title: 'Old issue' }), base({ title: 'New issue' })] + const result = compareFindings(baseline, comparison) + expect(result.newFindings).toHaveLength(1) + expect(result.newFindings[0].comparison?.title).toBe('New issue') + }) + + it('detects fixed findings removed in comparison scan', () => { + const baseline = [base({ title: 'Fixed issue' }), base({ title: 'Still here' })] + const comparison = [base({ title: 'Still here' })] + const result = compareFindings(baseline, comparison) + expect(result.fixedFindings).toHaveLength(1) + expect(result.fixedFindings[0].baseline?.title).toBe('Fixed issue') + }) + + it('detects unchanged findings with same severity', () => { + const baseline = [base({ title: 'Stable', severity: 'medium' })] + const comparison = [base({ title: 'Stable', severity: 'medium' })] + const result = compareFindings(baseline, comparison) + expect(result.unchangedFindings).toHaveLength(1) + expect(result.severityChanged).toHaveLength(0) + }) + + it('detects severity changes for matching findings', () => { + const baseline = [base({ title: 'Escalated', severity: 'medium' })] + const comparison = [base({ title: 'Escalated', severity: 'critical' })] + const result = compareFindings(baseline, comparison) + expect(result.severityChanged).toHaveLength(1) + expect(result.unchangedFindings).toHaveLength(0) + }) + + it('handles empty findings on both sides', () => { + const result = compareFindings([], []) + expect(result.newFindings).toHaveLength(0) + expect(result.fixedFindings).toHaveLength(0) + expect(result.unchangedFindings).toHaveLength(0) + expect(result.severityChanged).toHaveLength(0) + }) +})
{finding.title}
{finding.target}
+ {item.baseline.severity} → {item.comparison.severity} +
None
+ BASELINE_VS_COMPARISON // NEW_FIXED_UNCHANGED_SEVERITY +
+ Loading comparison data... +
+ At least two reports with findings are required to compare. Run scans that finish + with results, then refresh. +
+ Select two different reports to compare. +
Diff ready
+ {baselineReport?.name} → {comparisonReport?.name} +
+ No findings to compare for the selected reports. +