diff --git a/components/phishing/evidence-card.tsx b/components/phishing/evidence-card.tsx new file mode 100644 index 0000000..0c87341 --- /dev/null +++ b/components/phishing/evidence-card.tsx @@ -0,0 +1,353 @@ +'use client'; + +/* EvidenceCard — tabbed, read-only rendering of a reported message's parsed + * EML evidence: Headers, URLs, Attachments, Body preview, Blast Radius + * (REVIEW-03). + * + * This is the phase's biggest XSS-exposure surface: every value rendered + * here originates from an attacker-controlled email. Body preview is + * rendered ONLY as JSX text inside a
(React auto-escapes) — never via + * a raw-HTML injection prop. Extracted URLs are delegated to, which + * enforces the D-09 inert-copy-only contract. Attachment content is never + * fetched or rendered — filename/content-type/size/hash metadata only + * (EVID-04). */ + +import { useMemo, useState } from 'react'; +import { formatDistanceToNow } from 'date-fns'; +import { FileText, Copy } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { Button } from '@/components/ui/button'; +import { UrlList } from '@/components/phishing/url-list'; +import { toast } from 'sonner'; + +// ── Shapes (from the extended campaign detail route) ────────────────────── + +export interface EvidenceMessageHeaders { + from: { displayName: string | null; email: string | null; domain: string | null }; + replyTo: string | null; + returnPath: string | null; + to: string[]; + cc: string[]; + subject: string | null; + date: string | null; + messageId: string | null; + receivedChain: string[]; + authResults: { spf?: string | null; dkim?: string | null; dmarc?: string | null }; + authResultsOriginal?: Record | null; +} + +export interface EvidenceAttachment { + filename: string | null; + contentType: string | null; + size: number; + checksum: string | null; + related: boolean; +} + +export interface EvidenceMessage { + id: string; + ticketNumber: string | null; + reportCreatedAt: string | null; + headers: EvidenceMessageHeaders; + urls: string[]; + attachments: EvidenceAttachment[]; + bodyPreview: string; +} + +export type BlastRadiusResult = + | { status: 'unavailable'; reason: 'not_configured' | 'lookup_failed'; error?: string } + | { + status: 'ok'; + matched: number; + delivered: number; + held: number; + rejected: number; + clicked: number; + perRecipient: Array<{ recipient: string; status: 'delivered' | 'held' | 'rejected' | 'unknown' }>; + }; + +interface EvidenceCardProps { + messages: EvidenceMessage[]; + blastRadius: BlastRadiusResult; +} + +// ── Small local helpers ───────────────────────────────────────────────── + +function formatBytes(bytes: number | null): string { + if (!bytes) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + let val = bytes; + while (val >= 1024 && i < units.length - 1) { + val /= 1024; + i++; + } + return `${val.toFixed(1)} ${units[i]}`; +} + +function authBadge(value: string | null | undefined) { + const normalized = (value ?? '').toLowerCase(); + if (normalized === 'pass') { + return pass ; + } + if (normalized === 'fail') { + returnfail ; + } + return{normalized || 'none'} ; +} + +function recipientStatusBadge(status: string) { + switch (status) { + case 'delivered': + returndelivered ; + case 'held': + returnheld ; + case 'rejected': + returnrejected ; + default: + returnunknown ; + } +} + +async function copyToClipboard(value: string) { + await navigator.clipboard.writeText(value); + toast.success('Copied'); +} + +function unavailableCopy(result: { status: 'unavailable'; reason: 'not_configured' | 'lookup_failed'; error?: string }) { + if (result.reason === 'not_configured') { + return "Blast radius unavailable — Mimecast isn't configured for this environment."; + } + return `Blast radius lookup failed: ${result.error ?? 'Unknown error'}. Classification proceeded without it.`; +} + +// ── Component ─────────────────────────────────────────────────────────── + +export function EvidenceCard({ messages, blastRadius }: EvidenceCardProps) { + const [selectedId, setSelectedId] = useState(messages[0]?.id); + + const message = useMemo( + () => messages.find((m) => m.id === selectedId) ?? messages[0] ?? null, + [messages, selectedId], + ); + + return ( + + + ); +}+ ++ ++ Evidence + + {messages.length > 1 && ( + ++ ++ )} + + {!message ? ( +No message evidence available.
+ ) : ( ++ + )} ++ + +Headers +URLs +Attachments +Body preview +Blast Radius ++ + ++ From + {message.headers.from.email ?? '—'} + + Display name + {message.headers.from.displayName ?? '—'} + + Sender domain + {message.headers.from.domain ?? '—'} + + Reply-To + {message.headers.replyTo ?? '—'} + + Return-Path + {message.headers.returnPath ?? '—'} + + To + {message.headers.to.join(', ') || '—'} + + Cc + {message.headers.cc.join(', ') || '—'} + + Subject + {message.headers.subject ?? '—'} + + Date + {message.headers.date ?? '—'} + + Message-ID + {message.headers.messageId ?? '—'} + + SPF + {authBadge(message.headers.authResults.spf)} + + DKIM + {authBadge(message.headers.authResults.dkim)} + + DMARC + {authBadge(message.headers.authResults.dmarc)} ++ + {message.headers.receivedChain.length > 0 && ( ++ + )} ++ +Received chain ++ ++ {message.headers.receivedChain.map((hop, index) => ( +
+- + {hop} +
+ ))} ++ + ++ + {message.attachments.length === 0 ? ( + + +No attachments on this message.
+ ) : ( ++
+ )} ++ ++ +Filename +Content-Type +Size +Hash ++ {message.attachments.map((attachment, index) => ( + ++ + ))} +{attachment.filename ?? '—'} ++ ++ {attachment.contentType ?? 'unknown'} + +{formatBytes(attachment.size)} ++ {attachment.checksum ? ( + ++ + {attachment.checksum} + + ++ ) : ( + — + )} ++ + ++ {message.bodyPreview} +++ {blastRadius.status === 'unavailable' ? ( + +{unavailableCopy(blastRadius)}
+ ) : ( +++ )} +++++Matched+{blastRadius.matched}+++Delivered+{blastRadius.delivered}+++Held+{blastRadius.held}+++Rejected+{blastRadius.rejected}+++Clicked+{blastRadius.clicked}++
++ ++ +Recipient +Status ++ {blastRadius.perRecipient.map((recipient, index) => ( + ++ + ))} +{recipient.recipient} +{recipientStatusBadge(recipient.status)} +