/** * Campaign Classifier (Phase 19) * * Pure, deterministic SPAM/UNWANTED/THREAT rule engine over bounded, * structured phishing-triage evidence. No LLM/Anthropic/OpenRouter calls * anywhere in this module (D-01) — same evidence-in -> rule-eval -> * verdict-out shape as lib/services/robotic-classifier.ts. * * `classifyCampaign(campaignId)` (Task 2) is the single exported * orchestrator: gather evidence -> apply the D-06 (simulation allowlist) -> * D-03 (THREAT) -> D-04 (SPAM/UNWANTED) rule order -> compute D-05 * confidence -> map D-08 recommended actions -> append one classifications * row (D-02, no ON CONFLICT). This file currently implements the pure rule * functions those steps compose (Task 1). */ import type { AuthResults } from './eml-parser'; import { postgresClient } from './postgres-client'; import { getBlastRadius, type BlastRadiusResult } from './mimecast-blast-radius'; // ============================================================================= // D-06/D-07: KnowBe4 / Breach Secure Now simulation sender-domain allowlist // ============================================================================= /** * NOT exhaustive — see 19-RESEARCH.md "Pitfall 4" and "D-07 Findings" for * provenance and known gaps. Refresh from new ticket evidence or a vendor * domain export as needed. This is a TypeScript constant (not a * live-editable DB table) by design — T-19-02's logic-drift mitigation * relies on every rule, including this allowlist, being unit-tested pure * code rather than a runtime-editable table. */ export const KNOWN_SIMULATION_SENDERS: readonly { vendor: string; domains: readonly string[] }[] = [ { vendor: 'knowbe4', // 219 tickets, ~37 impersonated personas — 19-RESEARCH.md D-07 finding #2 domains: ['it-support.care'], }, { vendor: 'breach-secure-now', // confirmed via ticket #610787/#610770/#650284 — 19-RESEARCH.md D-07 finding #1 domains: ['breachsecurenow.com'], }, ]; /** * Exact-domain-or-proper-subdomain match ONLY — never `.includes()` * substring matching (T-19-01 spoofing guard: a domain like * `it-support.care.attacker.net` must NOT match). */ export function domainMatchesAllowlist(domain: string): boolean { const lower = domain.toLowerCase(); return KNOWN_SIMULATION_SENDERS.some((entry) => entry.domains.some((allowed) => lower === allowed || lower.endsWith(`.${allowed}`)) ); } /** * Narrow shape shared by both the full Phase 16 `NormalizedMessage` (used * directly by unit tests/fixtures) and this module's own bounded * `ParsedMessage` (built from `messages.headers` JSONB in * `gatherCampaignEvidence`) — only the sender-identity fields the allowlist * check needs. */ export interface SenderIdentity { from: { domain: string | null }; returnPath: string | null; } /** * Checks BOTH the From domain and the Return-Path domain (Pitfall 3 — From * may lack a visible email address on some real report tickets, e.g. the * Breach Secure Now fixture). */ export function isKnownSimulationSender(message: SenderIdentity): boolean { const fromDomain = message.from.domain; const returnPathDomain = message.returnPath?.split('@')[1] ?? null; return [fromDomain, returnPathDomain] .filter((domain): domain is string => domain !== null) .some(domainMatchesAllowlist); } // ============================================================================= // Auth-verdict precedence (Pitfall 1: forwarding-induced auth-verdict inversion) // ============================================================================= /** Prefers the pre-forwarding verdict when present (Pitfall 1). */ export function effectiveAuthResults(headers: { authResults: AuthResults; authResultsOriginal: AuthResults | null; }): AuthResults { return headers.authResultsOriginal ?? headers.authResults; } /** True iff any of spf/dkim/dmarc is a hard 'fail' — never on 'none'/'neutral'/undefined. */ export function hasHardAuthFail(auth: AuthResults): boolean { return auth.spf === 'fail' || auth.dkim === 'fail' || auth.dmarc === 'fail'; } // ============================================================================= // D-05: Confidence scoring — additive-from-1.0, each deduction named in reasons // ============================================================================= export interface ConfidenceResult { confidence: number; reasons: string[]; } export interface ConfidenceEvidenceFlags { hasAnyMessage: boolean; blastRadiusStatus: 'ok' | 'unavailable'; hasAttachmentOrUrlIndicators: boolean; } /** * Weights: message-parse absence (0.4) is heaviest since it starves every * other evidence source (no sender domain, no auth verdicts, no indicators * without a parsed message); Mimecast unavailability (0.3) is next since * D-03's THREAT gate directly depends on it; missing indicators (0.2) is * lightest since a genuinely clean message legitimately has none. The three * sum to 0.9, leaving a natural 0.10 floor when all three evidence sources * are missing — no extra clamping logic needed. */ export function computeConfidence(evidence: ConfidenceEvidenceFlags): ConfidenceResult { let confidence = 1.0; const reasons: string[] = []; if (!evidence.hasAnyMessage) { confidence -= 0.4; reasons.push( 'No .eml/message evidence parsed for any report in this campaign — sender-domain and auth-verdict signals unavailable' ); } if (evidence.blastRadiusStatus !== 'ok') { confidence -= 0.3; reasons.push('Mimecast blast-radius data unavailable — delivery/click evidence could not be confirmed'); } if (!evidence.hasAttachmentOrUrlIndicators) { confidence -= 0.2; reasons.push('No attachment-hash or URL indicators found for this campaign'); } return { confidence: Math.round(confidence * 100) / 100, reasons }; } // ============================================================================= // D-08: Recommended-actions vocabulary + requires_approval invariant // ============================================================================= export type Verdict = 'SPAM' | 'UNWANTED' | 'THREAT'; /** Always force requires_approval:true when recommended (CLASSIFY-02). */ export const DESTRUCTIVE_ACTIONS = new Set([ 'block_sender', 'purge_message', 'reset_password', 'isolate_endpoint', ]); export interface ActionEvidence { clicked: number; } export function mapVerdictToActions(verdict: Verdict, evidence: ActionEvidence): string[] { switch (verdict) { case 'SPAM': return ['no_action']; case 'UNWANTED': return ['warn_user']; case 'THREAT': { const actions = ['block_sender', 'purge_message']; // Evidence of actual interaction (not just delivery) raises the bar to // credential/endpoint-compromise-level actions. ASSUMPTION FLAG: this // is a reasoned research proposal (19-RESEARCH.md Open Question #1), // NOT an explicit D-08 decision — CONTEXT.md D-08 only locks the // vocabulary and the OR'd approval invariant, delegating finer // action-mapping to "Claude's Discretion". Worth a quick user // confirmation since it materially affects what Phase 20 gates // approval on; does not contradict any locked decision. if (evidence.clicked > 0) { actions.push('reset_password', 'isolate_endpoint', 'disable_forwarding_rule'); } return actions; } } } /** OR'd across all recommended actions (CLASSIFY-02) — never per-action. */ export function computeRequiresApproval(actions: string[]): boolean { return actions.some((action) => DESTRUCTIVE_ACTIONS.has(action)); } // ============================================================================= // Evidence gathering — gatherCampaignEvidence (CLASSIFY-06 / T-19-03 bounded) // ============================================================================= /** Cap on sample arrays exposed for human-readable evidence — never the full set (T-19-03). */ const MAX_SAMPLE_SIZE = 10; interface ReportDbRow { id: string; title: string | null; created_at: string; requester_email: string | null; } interface MessageDbRow { id: string; report_id: string; headers: Record | null; } interface IndicatorDbRow { id: string; message_id: string; indicator_type: string; value: string; } /** * Bounded, structured per-message fields needed by the rule engine — parsed * out of `messages.headers` JSONB (the Phase 16 `NormalizedMessage` object). * Deliberately narrower than the full `NormalizedMessage` shape: never * carries `bodyPreview`/raw content into the classifier or its output * (CLASSIFY-06). */ export interface ParsedMessage extends SenderIdentity { id: string; reportId: string; from: { domain: string | null; email: string | null }; authResults: AuthResults; authResultsOriginal: AuthResults | null; subject: string | null; } export interface CampaignIndicator { id: string; messageId: string; indicatorType: string; value: string; } export interface CampaignReportSummary { id: string; title: string | null; createdAt: string; requesterEmail: string | null; } export interface CampaignEvidence { campaignId: string; reportCount: number; messageCount: number; indicatorCount: number; /** Capped at MAX_SAMPLE_SIZE — for human-readable evidence only (T-19-03). */ reportSample: CampaignReportSummary[]; messages: ParsedMessage[]; indicators: CampaignIndicator[]; blastRadius: BlastRadiusResult; } /** * Gathers a bounded evidence payload for one campaign: linked reports * (earliest-first), their parsed messages, cross-message indicators, and a * single `getBlastRadius()` lookup keyed off the EARLIEST report (research * A6 — "the original" convention, mirroring campaign-grouping-service.ts's * own `ORDER BY r.created_at ASC`). When the campaign has no linked reports, * synthesizes `unavailable`/`not_configured` without calling Mimecast. */ export async function gatherCampaignEvidence(campaignId: string): Promise { // Bulk-fetch linked reports (+ join contacts for requester email — mirrors // app/api/phishing/campaigns/[id]/route.ts's existing bulk-fetch shape). const reportsRes = await postgresClient.query( `SELECT r.id::text AS id, r.title, r.created_at::text AS created_at, c.email_address AS requester_email FROM reports r LEFT JOIN contacts c ON c.id = r.requester_contact_id WHERE r.campaign_id = $1 ORDER BY r.created_at ASC`, [campaignId] ); const reports: CampaignReportSummary[] = reportsRes.rows.map((r) => ({ id: r.id, title: r.title, createdAt: r.created_at, requesterEmail: r.requester_email, })); const reportIds = reports.map((r) => r.id); const messagesRes = reportIds.length ? await postgresClient.query( `SELECT id::text AS id, report_id::text AS report_id, headers FROM messages WHERE report_id = ANY($1::uuid[])`, [reportIds] ) : { rows: [] as MessageDbRow[] }; const messages: ParsedMessage[] = messagesRes.rows.map((m) => { const headers = (m.headers ?? {}) as { from?: { domain?: string | null; email?: string | null }; returnPath?: string | null; authResults?: AuthResults; authResultsOriginal?: AuthResults | null; subject?: string | null; }; return { id: m.id, reportId: m.report_id, from: { domain: headers.from?.domain ?? null, email: headers.from?.email ?? null }, returnPath: headers.returnPath ?? null, authResults: headers.authResults ?? {}, authResultsOriginal: headers.authResultsOriginal ?? null, subject: headers.subject ?? null, }; }); const messageIds = messages.map((m) => m.id); const indicatorsRes = messageIds.length ? await postgresClient.query( `SELECT id::text AS id, message_id::text AS message_id, indicator_type, value FROM indicators WHERE message_id = ANY($1::uuid[])`, [messageIds] ) : { rows: [] as IndicatorDbRow[] }; const indicators: CampaignIndicator[] = indicatorsRes.rows.map((i) => ({ id: i.id, messageId: i.message_id, indicatorType: i.indicator_type, value: i.value, })); 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 createdAt = new Date(primaryReport.createdAt); blastRadius = await getBlastRadius({ sender: senderIndicator?.value ?? primaryMessage?.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 { // No report ever linked to this campaign — nothing to look up (research A6). blastRadius = { status: 'unavailable', reason: 'not_configured' }; } return { campaignId, reportCount: reports.length, messageCount: messages.length, indicatorCount: indicators.length, reportSample: reports.slice(0, MAX_SAMPLE_SIZE), messages, indicators, blastRadius, }; } // ============================================================================= // D-03/D-04: Verdict tier evaluation // ============================================================================= /** * Known-bad indicator match (D-03 second signal, research A4): cross-report * correlation only — same attachment_hash/url value spanning >=2 distinct * messages in the campaign. No external reputation lookup. */ function hasKnownBadIndicatorMatch(indicators: CampaignIndicator[]): boolean { const messagesByValue = new Map>(); for (const indicator of indicators) { if (indicator.indicatorType !== 'attachment_hash' && indicator.indicatorType !== 'url') continue; const key = `${indicator.indicatorType}:${indicator.value}`; const set = messagesByValue.get(key) ?? new Set(); set.add(indicator.messageId); messagesByValue.set(key, set); } return Array.from(messagesByValue.values()).some((set) => set.size >= 2); } /** * D-03: THREAT requires BOTH (a) evidence the message reached someone * (blast-radius delivered>0 or clicked>0) AND (b) a malicious signal — a * hard SPF/DKIM/DMARC fail (via effectiveAuthResults' authResultsOriginal * precedence) OR a known-bad indicator match. Either signal alone is not * enough — a contained blast radius isn't a realized threat yet. */ function evaluateThreatTier(evidence: CampaignEvidence): boolean { if (evidence.blastRadius.status !== 'ok') return false; const { delivered, clicked } = evidence.blastRadius; if (delivered <= 0 && clicked <= 0) return false; const hasAuthFail = evidence.messages.some((message) => hasHardAuthFail(effectiveAuthResults(message))); const hasIndicatorMatch = hasKnownBadIndicatorMatch(evidence.indicators); return hasAuthFail || hasIndicatorMatch; } /** * D-04: SPAM = no suspicious signal at all. UNWANTED = a suspicious signal * present (an attachment/url indicator match, even a single one, or * delivery contained to the reporter(s) only) but below the THREAT bar. */ function evaluateSpamVsUnwanted(evidence: CampaignEvidence): 'SPAM' | 'UNWANTED' { const hasAnyIndicator = evidence.indicators.some( (indicator) => indicator.indicatorType === 'attachment_hash' || indicator.indicatorType === 'url' ); const requesterEmails = evidence.reportSample .map((report) => report.requesterEmail) .filter((email): email is string => email !== null); const deliveryContainedToReporter = evidence.blastRadius.status === 'ok' && evidence.blastRadius.delivered > 0 && evidence.blastRadius.perRecipient .filter((recipient) => recipient.status === 'delivered') .every((recipient) => requesterEmails.includes(recipient.recipient)); return hasAnyIndicator || deliveryContainedToReporter ? 'UNWANTED' : 'SPAM'; } // ============================================================================= // classifyCampaign — orchestrator (CLASSIFY-01, D-02 append-only INSERT) // ============================================================================= export interface ClassifyResult { id: string; campaignId: string; verdict: Verdict; confidence: number; summary: string; reasons: string[]; recommendedActions: string[]; requiresApproval: boolean; createdAt: string; } /** * Single exported orchestrator: gather evidence -> D-06 simulation * short-circuit -> D-03 THREAT tier -> D-04 SPAM/UNWANTED split -> D-05 * confidence -> D-08 recommended actions -> append-only INSERT into * `classifications` (D-02, no ON CONFLICT — history is never overwritten). */ export async function classifyCampaign(campaignId: string): Promise { try { const evidence = await gatherCampaignEvidence(campaignId); // D-06: allowlist match short-circuits before THREAT tier evaluation, // regardless of other signals — D-03/D-04 still decide which of SPAM/ // UNWANTED applies. const isSimulation = evidence.messages.some((message) => isKnownSimulationSender(message)); let verdict: Verdict; const reasons: string[] = []; if (isSimulation) { verdict = evaluateSpamVsUnwanted(evidence); reasons.push( 'Sender domain matches a known phishing-simulation vendor allowlist (KnowBe4/Breach Secure Now) — THREAT tier skipped' ); } else if (evaluateThreatTier(evidence)) { verdict = 'THREAT'; } else { verdict = evaluateSpamVsUnwanted(evidence); } const clicked = evidence.blastRadius.status === 'ok' ? evidence.blastRadius.clicked : 0; const recommendedActions = mapVerdictToActions(verdict, { clicked }); const requiresApproval = computeRequiresApproval(recommendedActions); const { confidence, reasons: confidenceReasons } = computeConfidence({ hasAnyMessage: evidence.messageCount > 0, blastRadiusStatus: evidence.blastRadius.status === 'ok' ? 'ok' : 'unavailable', hasAttachmentOrUrlIndicators: evidence.indicators.some( (indicator) => indicator.indicatorType === 'attachment_hash' || indicator.indicatorType === 'url' ), }); reasons.push(...confidenceReasons); reasons.push( `Verdict ${verdict} determined from ${evidence.reportCount} linked report(s) and ${evidence.messageCount} parsed message(s)` ); const summary = `${verdict} (confidence ${confidence}): ${reasons[0]}`; // D-02: append-only INSERT — no ON CONFLICT. Each classify call is a new // history row; "current" verdict is the most recent by created_at. const insertResult = await postgresClient.query<{ id: string; created_at: string }>( `INSERT INTO classifications ( campaign_id, verdict, confidence, summary, reasons, recommended_actions, requires_approval ) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7) RETURNING id::text AS id, created_at::text AS created_at`, [ campaignId, verdict, confidence, summary, JSON.stringify(reasons), JSON.stringify(recommendedActions), requiresApproval, ] ); const row = insertResult.rows[0]; return { id: row.id, campaignId, verdict, confidence, summary, reasons, recommendedActions, requiresApproval, createdAt: row.created_at, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error('[CAMPAIGN-CLASSIFIER] Failed to classify campaign', campaignId, message); throw error; } }