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:
parent
f4e6baf505
commit
3ea6c95e38
1 changed files with 177 additions and 0 deletions
177
lib/services/campaign-classifier.ts
Normal file
177
lib/services/campaign-classifier.ts
Normal 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));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue