- Thread company_id through reports query and CampaignReportSummary
- Mirror the route's Bug 2 (D-05) tenant-resolution block: query
mimecast_tenants for an enabled row, build a tenant-scoped client via
getMimecastClientForTenant, and pass { client, cacheScope } to
getBlastRadius() when one exists
- Preserve global env fallback unchanged when no companyId or no
enabled tenant row is present
578 lines
22 KiB
TypeScript
578 lines
22 KiB
TypeScript
/**
|
|
* 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';
|
|
import { getMimecastClientForTenant } from './mimecast-client';
|
|
|
|
// =============================================================================
|
|
// 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.
|
|
// customer-portal.info, cloud-service-care.com, bankonlinesupport.com
|
|
// confirmed via shared /render-template/ + /tracking/ URL fingerprint
|
|
// across Seubert tickets 699419/699421/699422/699433/699435/699456 on
|
|
// 2026-07-16/17 (quick task 260717-a19).
|
|
domains: [
|
|
'it-support.care',
|
|
'customer-portal.info',
|
|
'cloud-service-care.com',
|
|
'bankonlinesupport.com',
|
|
],
|
|
},
|
|
{
|
|
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' | 'USER_AWARENESS';
|
|
|
|
/** 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;
|
|
}
|
|
case 'USER_AWARENESS':
|
|
return ['acknowledge_user'];
|
|
}
|
|
}
|
|
|
|
/** 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;
|
|
company_id: string | null;
|
|
}
|
|
|
|
/** Mirrors app/api/phishing/campaigns/[id]/route.ts's MimecastTenantRow shape verbatim. */
|
|
interface MimecastTenantRow {
|
|
client_id: string;
|
|
client_secret: string;
|
|
base_url: string | null;
|
|
}
|
|
|
|
interface MessageDbRow {
|
|
id: string;
|
|
report_id: string;
|
|
headers: Record<string, unknown> | 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;
|
|
companyId: 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<CampaignEvidence> {
|
|
// 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<ReportDbRow>(
|
|
`SELECT r.id::text AS id, r.title, r.created_at::text AS created_at,
|
|
c.email_address AS requester_email, r.company_id::text AS company_id
|
|
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,
|
|
companyId: r.company_id,
|
|
}));
|
|
const reportIds = reports.map((r) => r.id);
|
|
|
|
const messagesRes = reportIds.length
|
|
? await postgresClient.query<MessageDbRow>(
|
|
`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<IndicatorDbRow>(
|
|
`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);
|
|
|
|
// Bug 2 (D-05): resolve the reporting company's own registered Mimecast
|
|
// tenant, if one exists, and query it directly instead of the global
|
|
// env-configured (Wulf) tenant. Falls back to the global client when the
|
|
// company has no enabled mimecast_tenants row. Mirrors
|
|
// app/api/phishing/campaigns/[id]/route.ts's identical tenant-resolution
|
|
// block verbatim — same query, same client build, same options shape.
|
|
let tenantOptions: { client: ReturnType<typeof getMimecastClientForTenant>; cacheScope: string } | undefined;
|
|
if (primaryReport.companyId) {
|
|
const tenantRes = await postgresClient.query<MimecastTenantRow>(
|
|
`SELECT client_id, client_secret, base_url
|
|
FROM mimecast_tenants
|
|
WHERE company_id = $1 AND enabled = true
|
|
ORDER BY id LIMIT 1`,
|
|
[primaryReport.companyId]
|
|
);
|
|
const tenantRow = tenantRes.rows[0];
|
|
if (tenantRow) {
|
|
tenantOptions = {
|
|
client: getMimecastClientForTenant({
|
|
client_id: tenantRow.client_id,
|
|
client_secret: tenantRow.client_secret,
|
|
base_url: tenantRow.base_url ?? undefined,
|
|
}),
|
|
cacheScope: primaryReport.companyId,
|
|
};
|
|
}
|
|
}
|
|
|
|
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),
|
|
},
|
|
},
|
|
tenantOptions
|
|
);
|
|
} 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<string, Set<string>>();
|
|
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<string>();
|
|
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<ClassifyResult> {
|
|
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 = 'USER_AWARENESS';
|
|
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;
|
|
}
|
|
}
|