feat(19-01): implement classifier pure rule functions (D-05/D-06/D-08)

- KNOWN_SIMULATION_SENDERS allowlist (it-support.care, breachsecurenow.com)
  with domainMatchesAllowlist (exact-or-proper-subdomain, no substring match
  — T-19-01) and isKnownSimulationSender (checks From + Return-Path domain
  — Pitfall 3)
- effectiveAuthResults (authResultsOriginal precedence — Pitfall 1) and
  hasHardAuthFail (spf/dkim/dmarc hard-fail only)
- computeConfidence: additive-from-1.0 with 0.4/0.3/0.2 named deductions,
  floors at 0.10 (D-05)
- mapVerdictToActions + DESTRUCTIVE_ACTIONS + computeRequiresApproval
  (OR'd across actions, D-08/CLASSIFY-02)
- All 30 pure-function tests green; tsc clean
This commit is contained in:
lorentz 2026-07-16 08:14:57 -04:00
parent f4e6baf505
commit 3ea6c95e38

View file

@ -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));
}