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') {
+    return fail;
+  }
+  return {normalized || 'none'};
+}
+
+function recipientStatusBadge(status: string) {
+  switch (status) {
+    case 'delivered':
+      return delivered;
+    case 'held':
+      return held;
+    case 'rejected':
+      return rejected;
+    default:
+      return unknown;
+  }
+}
+
+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)} + + ))} + +
+
+ )} +
+
+ )} +
+
+ ); +}