diff --git a/lib/services/campaign-classifier.ts b/lib/services/campaign-classifier.ts new file mode 100644 index 0000000..472aab3 --- /dev/null +++ b/lib/services/campaign-classifier.ts @@ -0,0 +1,177 @@ +/** + * 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 { NormalizedMessage, AuthResults } from './eml-parser'; + +// ============================================================================= +// 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}`)) + ); +} + +/** + * 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: NormalizedMessage): 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)); +}