feat(22-02): extend campaign detail route with evidence, timeline, blast radius
- Widen messages query to include headers/urls/attachments/body_preview - Widen classifications query to include reasons/recommendedActions/ requiresApproval - Add remediationActions (with completedAt derived from audit_events payload.actionId, no completion-timestamp column exists) and auditEvents to the response - Add fresh per-request blastRadius via getBlastRadius(), sender/ recipient/subject derivation copied from campaign-classifier.ts's gatherCampaignEvidence (not triage-note-service's empty-string call) - Add mergeTimeline()-derived chronological timeline - All additive — existing fields, UUID_RE guard, and auth gate unchanged
This commit is contained in:
parent
ca63910562
commit
9e83ec09ee
1 changed files with 172 additions and 35 deletions
|
|
@ -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<MessageRow>(
|
||||
`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<ClassificationRow>(
|
||||
`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<RemediationActionRow>(
|
||||
`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<AuditEventRow>(
|
||||
`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<string, string>();
|
||||
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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue