diff --git a/app/api/phishing/campaigns/[id]/route.ts b/app/api/phishing/campaigns/[id]/route.ts index 29808e3..1fb4b9c 100644 --- a/app/api/phishing/campaigns/[id]/route.ts +++ b/app/api/phishing/campaigns/[id]/route.ts @@ -1,12 +1,16 @@ /** * GET /api/phishing/campaigns/[id] * Returns a single campaign with nested linked reports, messages, - * indicators, and classification history. + * indicators, classification history, remediation actions, audit trail, + * a fresh blast-radius lookup, and a merged chronological timeline — + * everything the Phase 22 review page needs in one call. */ import { NextRequest, NextResponse } from 'next/server'; import { requirePermission } from '@/lib/auth-utils'; import postgresClient from '@/lib/services/postgres-client'; +import { getBlastRadius, type BlastRadiusResult } from '@/lib/services/mimecast-blast-radius'; +import { mergeTimeline } from '@/lib/services/phishing-timeline'; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -37,6 +41,10 @@ interface MessageRow { report_id: string; message_id: string | null; subject: string | null; + headers: unknown; + urls: unknown; + attachments: unknown; + body_preview: string | null; } interface IndicatorRow { @@ -52,6 +60,26 @@ interface ClassificationRow { verdict: string; confidence: string | null; summary: string | null; + reasons: unknown; + recommended_actions: unknown; + requires_approval: boolean; + created_at: string; +} + +interface RemediationActionRow { + id: string; + action_type: string; + status: string; + params: unknown; + approved_by: string | null; + approved_at: string | null; +} + +interface AuditEventRow { + id: string; + actor: string | null; + event_type: string; + payload: unknown; created_at: string; } @@ -96,10 +124,13 @@ export async function GET( const reportIds = reportsRes.rows.map((r) => r.id); // Bulk-fetch messages keyed by the report-id array. messages has no - // subject column — subject lives in headers JSONB. + // subject column — subject lives in headers JSONB. Widened to also + // return the full evidence shapes (headers/urls/attachments/body_preview) + // for the review page's evidence card. const messagesRes = reportIds.length ? await postgresClient.query( - `SELECT id::text, report_id::text, message_id, headers->>'subject' AS subject + `SELECT id::text, report_id::text, message_id, headers->>'subject' AS subject, + headers, urls, attachments, body_preview FROM messages WHERE report_id = ANY($1::uuid[])`, [reportIds] ) @@ -115,14 +146,141 @@ export async function GET( ) : { rows: [] as IndicatorRow[] }; - // Classifications (Phase 19 stub — likely empty this phase, still - // included in the response shape per CAMP-03). + // Classifications — widened to include reasons/recommended_actions/ + // requires_approval for the review page's classification card. const classificationsRes = await postgresClient.query( - `SELECT id::text, verdict, confidence, summary, created_at::text + `SELECT id::text, verdict, confidence, summary, reasons, recommended_actions, + requires_approval, created_at::text FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC`, [id] ); + // Remediation actions proposed/approved/executed for this campaign. + const remediationRes = await postgresClient.query( + `SELECT id::text, action_type, status, params, approved_by, approved_at::text + FROM remediation_actions WHERE campaign_id = $1 ORDER BY created_at ASC`, + [id] + ); + + // Audit trail — approvals, completions, false-positive markings. + const auditRes = await postgresClient.query( + `SELECT id::text, actor, event_type, payload, created_at::text + FROM audit_events WHERE campaign_id = $1 ORDER BY created_at ASC`, + [id] + ); + + const reports = reportsRes.rows.map((r) => ({ + id: r.id, + ticketId: r.ticket_id, + ticketNumber: r.ticket_number, + companyName: r.company_name, + title: r.title, + createdAt: r.created_at, + requesterEmail: r.requester_email, + })); + + const messages = messagesRes.rows.map((m) => ({ + id: m.id, + reportId: m.report_id, + messageId: m.message_id, + subject: m.subject, + headers: m.headers, + urls: m.urls, + attachments: m.attachments, + bodyPreview: m.body_preview, + })); + + const indicators = indicatorsRes.rows.map((i) => ({ + id: i.id, + messageId: i.message_id, + indicatorType: i.indicator_type, + value: i.value, + metadata: i.metadata, + })); + + const classifications = classificationsRes.rows.map((c) => ({ + id: c.id, + verdict: c.verdict, + confidence: c.confidence, + summary: c.summary, + reasons: c.reasons, + recommendedActions: c.recommended_actions, + requiresApproval: c.requires_approval, + createdAt: c.created_at, + })); + + // Derive `completedAt` for each remediation action from the audit trail + // (remediation_actions has no dedicated completion-timestamp column) — + // the 'remediation_completed' audit event's payload.actionId points back + // at the action it completed. + const completedAtByActionId = new Map(); + for (const event of auditRes.rows) { + if (event.event_type === 'remediation_completed') { + const actionId = (event.payload as { actionId?: string } | null)?.actionId; + if (actionId) completedAtByActionId.set(actionId, event.created_at); + } + } + + const remediationActions = remediationRes.rows.map((a) => ({ + id: a.id, + actionType: a.action_type, + status: a.status, + params: a.params, + approvedBy: a.approved_by, + approvedAt: a.approved_at, + completedAt: completedAtByActionId.get(a.id) ?? null, + })); + + const auditEvents = auditRes.rows.map((e) => ({ + id: e.id, + actor: e.actor, + eventType: e.event_type, + payload: e.payload, + createdAt: e.created_at, + })); + + // Fresh blast-radius lookup per request (D-03: never persisted here). + // Sender/recipient/subject/dateWindow derivation copied verbatim from + // lib/services/campaign-classifier.ts's gatherCampaignEvidence — NOT + // triage-note-service.ts's empty-string call (Pitfall 3), which would + // produce an unscoped Mimecast fan-out. + const primaryReport = reports[0] ?? null; + let blastRadius: BlastRadiusResult; + if (primaryReport) { + const primaryMessage = messages.find((m) => m.reportId === primaryReport.id) ?? null; + const senderIndicator = indicators.find( + (i) => i.messageId === primaryMessage?.id && i.indicatorType === 'sender' + ); + const messageHeaders = (primaryMessage?.headers ?? null) as + | { from?: { email?: string | null } | null } + | null; + const createdAt = new Date(primaryReport.createdAt); + blastRadius = await getBlastRadius({ + sender: senderIndicator?.value ?? messageHeaders?.from?.email ?? '', + recipient: primaryReport.requesterEmail ?? '', + subject: primaryMessage?.subject ?? primaryReport.title ?? '', + dateWindow: { + start: new Date(createdAt.getTime() - 24 * 60 * 60 * 1000), + end: new Date(createdAt.getTime() + 24 * 60 * 60 * 1000), + }, + }); + } else { + blastRadius = { status: 'unavailable', reason: 'not_configured' }; + } + + // Merged chronological timeline (reports + classifications + audit + // events), oldest first. + const timeline = mergeTimeline( + reports.map((r) => ({ + createdAt: r.createdAt, + reportId: r.id, + ticketNumber: r.ticketNumber, + companyName: r.companyName, + })), + classifications, + auditEvents + ); + return NextResponse.json({ id: campaign.id, campaignKey: campaign.campaign_key, @@ -133,35 +291,14 @@ export async function GET( status: campaign.status, createdAt: campaign.created_at, updatedAt: campaign.updated_at, - reports: reportsRes.rows.map((r) => ({ - id: r.id, - ticketId: r.ticket_id, - ticketNumber: r.ticket_number, - companyName: r.company_name, - title: r.title, - createdAt: r.created_at, - requesterEmail: r.requester_email, - })), - messages: messagesRes.rows.map((m) => ({ - id: m.id, - reportId: m.report_id, - messageId: m.message_id, - subject: m.subject, - })), - indicators: indicatorsRes.rows.map((i) => ({ - id: i.id, - messageId: i.message_id, - indicatorType: i.indicator_type, - value: i.value, - metadata: i.metadata, - })), - classifications: classificationsRes.rows.map((c) => ({ - id: c.id, - verdict: c.verdict, - confidence: c.confidence, - summary: c.summary, - createdAt: c.created_at, - })), + reports, + messages, + indicators, + classifications, + remediationActions, + auditEvents, + blastRadius, + timeline, }); } catch (err) { console.error('[PHISHING-CAMPAIGN-DETAIL] Failed to load campaign', id, err);