384 lines
16 KiB
TypeScript
384 lines
16 KiB
TypeScript
'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 <pre> (React auto-escapes) — never via
|
|
* a raw-HTML injection prop. Extracted URLs are delegated to <UrlList>, 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?: { spf?: string | null; dkim?: string | null; dmarc?: string | null } | 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 <StatusBadge variantClass="bg-green-500/15 text-green-600">pass</StatusBadge>;
|
|
}
|
|
if (normalized === 'fail') {
|
|
return <StatusBadge variantClass="bg-red-500/15 text-red-600">fail</StatusBadge>;
|
|
}
|
|
return <StatusBadge variantClass="bg-slate-500/15 text-slate-600">{normalized || 'none'}</StatusBadge>;
|
|
}
|
|
|
|
/**
|
|
* Mirrors campaign-classifier.ts's effectiveAuthResults(): the outer
|
|
* Authentication-Results header reflects the reporting/forwarding hop, not
|
|
* the reported message's real delivery — it's near-universally broken for
|
|
* tickets that came through Outlook's Report Message add-in. Prefer the
|
|
* pre-forward authResultsOriginal when captured; only fall back to the
|
|
* outer header when no original was recorded (e.g. a manually-forwarded or
|
|
* KnowBe4 PhishER-sourced ticket).
|
|
*/
|
|
function effectiveAuthResults(headers: EvidenceMessageHeaders | undefined): {
|
|
result: EvidenceMessageHeaders['authResults'];
|
|
isOriginal: boolean;
|
|
} {
|
|
if (!headers) {
|
|
return { result: {}, isOriginal: false };
|
|
}
|
|
if (headers.authResultsOriginal) {
|
|
return { result: headers.authResultsOriginal, isOriginal: true };
|
|
}
|
|
return { result: headers.authResults, isOriginal: false };
|
|
}
|
|
|
|
function recipientStatusBadge(status: string) {
|
|
switch (status) {
|
|
case 'delivered':
|
|
return <StatusBadge variantClass="bg-green-500/15 text-green-600">delivered</StatusBadge>;
|
|
case 'held':
|
|
return <StatusBadge variantClass="bg-amber-500/15 text-amber-600">held</StatusBadge>;
|
|
case 'rejected':
|
|
return <StatusBadge variantClass="bg-destructive/15 text-destructive">rejected</StatusBadge>;
|
|
default:
|
|
return <StatusBadge variantClass="bg-muted text-muted-foreground">unknown</StatusBadge>;
|
|
}
|
|
}
|
|
|
|
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<string | undefined>(messages[0]?.id);
|
|
|
|
const message = useMemo(
|
|
() => messages.find((m) => m.id === selectedId) ?? messages[0] ?? null,
|
|
[messages, selectedId],
|
|
);
|
|
|
|
const authResults = useMemo(() => effectiveAuthResults(message?.headers), [message]);
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="font-bold">
|
|
<FileText className="h-4 w-4 mr-2 inline" />
|
|
Evidence
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{messages.length > 1 && (
|
|
<div className="mb-4">
|
|
<Select value={message?.id} onValueChange={setSelectedId}>
|
|
<SelectTrigger className="w-full sm:w-80">
|
|
<SelectValue placeholder="Select a report" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{messages.map((m) => (
|
|
<SelectItem key={m.id} value={m.id}>
|
|
Report: Ticket #{m.ticketNumber ?? '—'}
|
|
{m.reportCreatedAt
|
|
? ` — ${formatDistanceToNow(new Date(m.reportCreatedAt), { addSuffix: true })}`
|
|
: ''}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{!message ? (
|
|
<p className="text-sm text-muted-foreground">No message evidence available.</p>
|
|
) : (
|
|
<Tabs defaultValue="headers">
|
|
<TabsList>
|
|
<TabsTrigger value="headers">Headers</TabsTrigger>
|
|
<TabsTrigger value="urls">URLs</TabsTrigger>
|
|
<TabsTrigger value="attachments">Attachments</TabsTrigger>
|
|
<TabsTrigger value="body">Body preview</TabsTrigger>
|
|
<TabsTrigger value="blast-radius">Blast Radius</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="headers">
|
|
<div className="grid grid-cols-[8rem_minmax(0,1fr)] gap-x-4 gap-y-2 text-sm">
|
|
<span className="text-muted-foreground">From</span>
|
|
<span className="font-mono text-xs break-all">{message.headers.from.email ?? '—'}</span>
|
|
|
|
<span className="text-muted-foreground">Display name</span>
|
|
<span>{message.headers.from.displayName ?? '—'}</span>
|
|
|
|
<span className="text-muted-foreground">Sender domain</span>
|
|
<span className="font-mono text-xs break-all">{message.headers.from.domain ?? '—'}</span>
|
|
|
|
<span className="text-muted-foreground">Reply-To</span>
|
|
<span className="font-mono text-xs break-all">{message.headers.replyTo ?? '—'}</span>
|
|
|
|
<span className="text-muted-foreground">Return-Path</span>
|
|
<span className="font-mono text-xs break-all">{message.headers.returnPath ?? '—'}</span>
|
|
|
|
<span className="text-muted-foreground">To</span>
|
|
<span className="font-mono text-xs break-all">{message.headers.to.join(', ') || '—'}</span>
|
|
|
|
<span className="text-muted-foreground">Cc</span>
|
|
<span className="font-mono text-xs break-all">{message.headers.cc.join(', ') || '—'}</span>
|
|
|
|
<span className="text-muted-foreground">Subject</span>
|
|
<span>{message.headers.subject ?? '—'}</span>
|
|
|
|
<span className="text-muted-foreground">Date</span>
|
|
<span className="font-mono text-xs break-all">{message.headers.date ?? '—'}</span>
|
|
|
|
<span className="text-muted-foreground">Message-ID</span>
|
|
<span className="font-mono text-xs break-all">{message.headers.messageId ?? '—'}</span>
|
|
|
|
<span className="text-muted-foreground">SPF</span>
|
|
<span>{authBadge(authResults.result.spf)}</span>
|
|
|
|
<span className="text-muted-foreground">DKIM</span>
|
|
<span>{authBadge(authResults.result.dkim)}</span>
|
|
|
|
<span className="text-muted-foreground">DMARC</span>
|
|
<span>{authBadge(authResults.result.dmarc)}</span>
|
|
|
|
<span />
|
|
<span className="text-xs text-muted-foreground">
|
|
{authResults.isOriginal
|
|
? 'as verified at original delivery'
|
|
: 'as received (no pre-forward record)'}
|
|
</span>
|
|
</div>
|
|
|
|
{message.headers.receivedChain.length > 0 && (
|
|
<Accordion type="single" collapsible className="mt-4">
|
|
<AccordionItem value="received-chain">
|
|
<AccordionTrigger className="text-sm">Received chain</AccordionTrigger>
|
|
<AccordionContent>
|
|
<ul className="flex flex-col gap-1">
|
|
{message.headers.receivedChain.map((hop, index) => (
|
|
<li key={index} className="font-mono text-xs break-all">
|
|
{hop}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
</Accordion>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="urls">
|
|
<UrlList urls={message.urls} />
|
|
</TabsContent>
|
|
|
|
<TabsContent value="attachments">
|
|
{message.attachments.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">No attachments on this message.</p>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Filename</TableHead>
|
|
<TableHead>Content-Type</TableHead>
|
|
<TableHead>Size</TableHead>
|
|
<TableHead>Hash</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{message.attachments.map((attachment, index) => (
|
|
<TableRow key={index}>
|
|
<TableCell className="text-sm">{attachment.filename ?? '—'}</TableCell>
|
|
<TableCell>
|
|
<StatusBadge variantClass="bg-slate-500/15 text-slate-600">
|
|
{attachment.contentType ?? 'unknown'}
|
|
</StatusBadge>
|
|
</TableCell>
|
|
<TableCell className="text-sm">{formatBytes(attachment.size)}</TableCell>
|
|
<TableCell>
|
|
{attachment.checksum ? (
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className="font-mono text-xs truncate max-w-40"
|
|
title={attachment.checksum}
|
|
>
|
|
{attachment.checksum}
|
|
</span>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
aria-label="Copy hash"
|
|
onClick={() => copyToClipboard(attachment.checksum as string)}
|
|
>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<span className="text-muted-foreground text-xs">—</span>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="body">
|
|
<pre className="max-h-96 overflow-y-auto whitespace-pre-wrap rounded-md bg-muted p-4 font-mono text-xs">
|
|
{message.bodyPreview}
|
|
</pre>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="blast-radius">
|
|
{blastRadius.status === 'unavailable' ? (
|
|
<p className="text-sm text-muted-foreground">{unavailableCopy(blastRadius)}</p>
|
|
) : (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="grid grid-cols-5 gap-4 text-sm">
|
|
<div>
|
|
<div className="text-muted-foreground text-xs">Matched</div>
|
|
<div className="num">{blastRadius.matched}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-muted-foreground text-xs">Delivered</div>
|
|
<div className="num">{blastRadius.delivered}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-muted-foreground text-xs">Held</div>
|
|
<div className="num">{blastRadius.held}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-muted-foreground text-xs">Rejected</div>
|
|
<div className="num">{blastRadius.rejected}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-muted-foreground text-xs">Clicked</div>
|
|
<div className="num">{blastRadius.clicked}</div>
|
|
</div>
|
|
</div>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Recipient</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{blastRadius.perRecipient.map((recipient, index) => (
|
|
<TableRow key={index}>
|
|
<TableCell className="font-mono text-xs">{recipient.recipient}</TableCell>
|
|
<TableCell>{recipientStatusBadge(recipient.status)}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|