Merge branch 'worktree-agent-a432f929ffab2e5b8'

# Conflicts:
#	.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/deferred-items.md
This commit is contained in:
lorentz 2026-07-16 14:33:16 -04:00
commit 75cd454ff8
5 changed files with 571 additions and 1 deletions

View file

@ -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 <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?: Record<string, unknown> | 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>;
}
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],
);
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-2 gap-x-6 gap-y-2 text-sm">
<span className="text-muted-foreground">From</span>
<span className="font-mono text-xs">{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">{message.headers.from.domain ?? '—'}</span>
<span className="text-muted-foreground">Reply-To</span>
<span className="font-mono text-xs">{message.headers.replyTo ?? '—'}</span>
<span className="text-muted-foreground">Return-Path</span>
<span className="font-mono text-xs">{message.headers.returnPath ?? '—'}</span>
<span className="text-muted-foreground">To</span>
<span className="font-mono text-xs">{message.headers.to.join(', ') || '—'}</span>
<span className="text-muted-foreground">Cc</span>
<span className="font-mono text-xs">{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">{message.headers.date ?? '—'}</span>
<span className="text-muted-foreground">Message-ID</span>
<span className="font-mono text-xs truncate">{message.headers.messageId ?? '—'}</span>
<span className="text-muted-foreground">SPF</span>
<span>{authBadge(message.headers.authResults.spf)}</span>
<span className="text-muted-foreground">DKIM</span>
<span>{authBadge(message.headers.authResults.dkim)}</span>
<span className="text-muted-foreground">DMARC</span>
<span>{authBadge(message.headers.authResults.dmarc)}</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>
);
}

View file

@ -0,0 +1,50 @@
'use client';
/* UrlList inert, copy-only rendering of URLs extracted from a reported
* phishing/spam email (REVIEW-03, D-09).
*
* D-09 is a deliberately STRICTER-than-sanitization posture: extracted URLs
* are attacker-controlled content and must never be rendered as anything
* clickable. There is no anchor tag with a navigation attribute, no
* `<Link>`, and no navigating `onClick` anywhere in this file the only
* affordance is copy-to-clipboard via an icon-only button. Do not "improve"
* this by adding a real link. */
import { Copy } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
interface UrlListProps {
urls: string[];
}
export function UrlList({ urls }: UrlListProps) {
if (urls.length === 0) {
return (
<p className="text-sm text-muted-foreground">No URLs found in this message.</p>
);
}
async function handleCopy(url: string) {
await navigator.clipboard.writeText(url);
toast.success('Copied');
}
return (
<ul className="flex flex-col gap-2">
{urls.map((url, index) => (
<li key={`${url}-${index}`} className="flex items-center gap-2">
<code className="font-mono text-xs truncate flex-1">{url}</code>
<Button
size="icon"
variant="ghost"
aria-label="Copy URL"
onClick={() => handleCopy(url)}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</li>
))}
</ul>
);
}

57
components/ui/tooltip.tsx Normal file
View file

@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }