From f4e6baf5058b8c2776a7b2e2dfdfb5160686ed8c Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 08:14:51 -0400 Subject: [PATCH] test(19-01): add failing tests + synthetic fixtures for classifier pure rule functions - campaign-classifier.test.ts: describe blocks for domainMatchesAllowlist, isKnownSimulationSender, effectiveAuthResults, hasHardAuthFail, computeConfidence, mapVerdictToActions, computeRequiresApproval - campaign-classifier.fixtures.ts: synthetic KnowBe4/BSN simulation fixtures plus non-simulation threat/clean-spam/suspicious-unwanted fixtures - Covers CLASSIFY-01/02/03/04/06 pure-function behavior (T-19-01, Pitfall 1/3) --- lib/services/campaign-classifier.fixtures.ts | 118 ++++++++++++ lib/services/campaign-classifier.test.ts | 189 +++++++++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 lib/services/campaign-classifier.fixtures.ts create mode 100644 lib/services/campaign-classifier.test.ts diff --git a/lib/services/campaign-classifier.fixtures.ts b/lib/services/campaign-classifier.fixtures.ts new file mode 100644 index 0000000..18a0407 --- /dev/null +++ b/lib/services/campaign-classifier.fixtures.ts @@ -0,0 +1,118 @@ +/** + * Synthetic fixtures for campaign-classifier.test.ts (Phase 19). Nothing + * here is real customer content — all addresses, domains, and subjects are + * invented for testing only (per this milestone's explicit + * synthetic-fixture-only constraint). + * + * Both simulation fixtures (`knowbe4SimMessage`, `bsnSimMessage`) reproduce + * the forwarding-induced auth-verdict inversion described in + * 19-RESEARCH.md Pitfall 1 — the primary `authResults` header shows a hard + * fail (post-forward, DKIM invalidated by the forward hop) while + * `authResultsOriginal` shows the pre-forward pass. This lets the + * "simulation is never THREAT" test prove BOTH the D-06 allowlist + * short-circuit AND the D-05/authResultsOriginal precedence in one fixture. + */ + +import type { NormalizedMessage } from './eml-parser'; + +function makeNormalizedMessage( + overrides: Partial & { + from: NormalizedMessage['from']; + authResults: NormalizedMessage['authResults']; + } +): NormalizedMessage { + return { + replyTo: null, + returnPath: null, + to: ['reporter@wulfconsulting.test'], + cc: [], + subject: 'Test subject', + date: '2026-07-15T12:00:00.000Z', + messageId: null, + receivedChain: [], + authResultsOriginal: null, + urls: [], + attachments: [], + bodyPreview: '', + ...overrides, + }; +} + +/** + * KnowBe4 phishing-simulation fixture — From domain matches the + * `it-support.care` allowlist entry (19-RESEARCH.md D-07 finding #2). + */ +export const knowbe4SimMessage: NormalizedMessage = makeNormalizedMessage({ + from: { displayName: 'IT Support', email: 'alert@it-support.care', domain: 'it-support.care' }, + returnPath: 'bounce@it-support.care', + subject: 'Phishing Alert - Email Security Report', + authResults: { spf: 'fail', dkim: 'fail', dmarc: 'fail' }, + authResultsOriginal: { spf: 'pass', dkim: 'pass', dmarc: 'pass' }, +}); + +/** + * Breach Secure Now training-notification fixture — From.domain is null + * (Pitfall 3: From may lack a visible email address); the Return-Path + * domain is the only allowlist signal (19-RESEARCH.md D-07 finding #1). + */ +export const bsnSimMessage: NormalizedMessage = makeNormalizedMessage({ + from: { displayName: null, email: null, domain: null }, + returnPath: 'bounces-abc123@em8721.breachsecurenow.com', + subject: 'Security Awareness Training Notification', + authResults: { spf: 'fail', dkim: 'fail', dmarc: 'fail' }, + authResultsOriginal: { spf: 'pass', dkim: 'pass', dmarc: 'pass' }, +}); + +/** + * Non-simulation THREAT fixture — a real typosquat flavor per + * 19-RESEARCH.md D-07 finding #3 (`mlcrosoft.live`, NOT allowlisted). No + * `authResultsOriginal` — effectiveAuthResults falls back to the primary + * `authResults`, which itself shows a hard fail (no forwarding inversion + * here — the fail is the actual signal). + */ +export const threatMessage: NormalizedMessage = makeNormalizedMessage({ + from: { + displayName: 'Microsoft Account Team', + email: 'security@mlcrosoft.live', + domain: 'mlcrosoft.live', + }, + returnPath: 'bounce@mlcrosoft.live', + subject: 'Unusual sign-in activity detected', + authResults: { spf: 'fail', dkim: 'fail', dmarc: 'fail' }, + authResultsOriginal: null, +}); + +/** + * Clean non-simulation SPAM fixture — generic bulk/newsletter sender, no + * spoofing, all auth verdicts pass, no attachment/url indicators. + */ +export const cleanSpamMessage: NormalizedMessage = makeNormalizedMessage({ + from: { + displayName: 'Example Newsletter', + email: 'news@mail.example-newsletter.com', + domain: 'mail.example-newsletter.com', + }, + returnPath: 'bounce@mail.example-newsletter.com', + subject: 'Your weekly digest', + authResults: { spf: 'pass', dkim: 'pass', dmarc: 'pass' }, + authResultsOriginal: null, +}); + +/** + * Suspicious-but-contained UNWANTED fixture — a single suspicious signal + * (paired in Task 2 with exactly one url indicator, not shared across + * messages) with delivery contained to the reporter only — below the + * THREAT bar per D-04. + */ +export const suspiciousUnwantedMessage: NormalizedMessage = makeNormalizedMessage({ + from: { + displayName: 'Vendor Promo', + email: 'promo@promo.some-vendor.net', + domain: 'promo.some-vendor.net', + }, + returnPath: 'bounce@promo.some-vendor.net', + subject: 'Special offer just for you', + authResults: { spf: 'pass', dkim: 'pass', dmarc: 'pass' }, + authResultsOriginal: null, + urls: ['http://promo.some-vendor.net/deal'], +}); diff --git a/lib/services/campaign-classifier.test.ts b/lib/services/campaign-classifier.test.ts new file mode 100644 index 0000000..2c1a7e3 --- /dev/null +++ b/lib/services/campaign-classifier.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from 'vitest'; +import { + KNOWN_SIMULATION_SENDERS, + domainMatchesAllowlist, + isKnownSimulationSender, + effectiveAuthResults, + hasHardAuthFail, + computeConfidence, + mapVerdictToActions, + computeRequiresApproval, +} from './campaign-classifier'; +import { + knowbe4SimMessage, + bsnSimMessage, + threatMessage, + cleanSpamMessage, +} from './campaign-classifier.fixtures'; + +describe('KNOWN_SIMULATION_SENDERS', () => { + it('includes both the KnowBe4 and Breach Secure Now sender domains', () => { + const allDomains = KNOWN_SIMULATION_SENDERS.flatMap((entry) => entry.domains); + expect(allDomains).toContain('it-support.care'); + expect(allDomains).toContain('breachsecurenow.com'); + }); +}); + +describe('domainMatchesAllowlist', () => { + it('matches an exact allowlisted domain', () => { + expect(domainMatchesAllowlist('it-support.care')).toBe(true); + }); + + it('matches a proper subdomain of an allowlisted domain', () => { + expect(domainMatchesAllowlist('sub.it-support.care')).toBe(true); + expect(domainMatchesAllowlist('em8721.breachsecurenow.com')).toBe(true); + }); + + it('does NOT match a bare substring / suffix-spoofed domain (T-19-01)', () => { + expect(domainMatchesAllowlist('it-support.care.attacker.net')).toBe(false); + expect(domainMatchesAllowlist('evil-it-support.care')).toBe(false); + }); +}); + +describe('isKnownSimulationSender', () => { + it('matches on From domain (knowbe4SimMessage)', () => { + expect(isKnownSimulationSender(knowbe4SimMessage)).toBe(true); + }); + + it('matches on Return-Path domain when From.domain is null (Pitfall 3, bsnSimMessage)', () => { + expect(bsnSimMessage.from.domain).toBeNull(); + expect(isKnownSimulationSender(bsnSimMessage)).toBe(true); + }); + + it('does not match a non-allowlisted sender', () => { + expect(isKnownSimulationSender(threatMessage)).toBe(false); + expect(isKnownSimulationSender(cleanSpamMessage)).toBe(false); + }); +}); + +describe('effectiveAuthResults', () => { + it('returns authResultsOriginal when present (Pitfall 1)', () => { + expect(effectiveAuthResults(knowbe4SimMessage)).toEqual(knowbe4SimMessage.authResultsOriginal); + }); + + it('falls back to authResults when authResultsOriginal is null', () => { + expect(threatMessage.authResultsOriginal).toBeNull(); + expect(effectiveAuthResults(threatMessage)).toEqual(threatMessage.authResults); + }); +}); + +describe('hasHardAuthFail', () => { + it('is true when spf is fail', () => { + expect(hasHardAuthFail({ spf: 'fail' })).toBe(true); + }); + + it('is true when dkim is fail', () => { + expect(hasHardAuthFail({ dkim: 'fail' })).toBe(true); + }); + + it('is true when dmarc is fail', () => { + expect(hasHardAuthFail({ dmarc: 'fail' })).toBe(true); + }); + + it('is false for none/neutral/undefined verdicts', () => { + expect(hasHardAuthFail({ spf: 'none', dkim: 'neutral' })).toBe(false); + expect(hasHardAuthFail({})).toBe(false); + }); + + it('is false when all verdicts pass', () => { + expect(hasHardAuthFail({ spf: 'pass', dkim: 'pass', dmarc: 'pass' })).toBe(false); + }); +}); + +describe('computeConfidence', () => { + it('is 1.0 with no deductions and no reasons when all evidence is present (confidence deduction baseline)', () => { + const result = computeConfidence({ + hasAnyMessage: true, + blastRadiusStatus: 'ok', + hasAttachmentOrUrlIndicators: true, + }); + expect(result.confidence).toBe(1.0); + expect(result.reasons).toHaveLength(0); + }); + + it('deducts 0.4 and names the reason when no message was parsed (confidence deduction)', () => { + const result = computeConfidence({ + hasAnyMessage: false, + blastRadiusStatus: 'ok', + hasAttachmentOrUrlIndicators: true, + }); + expect(result.confidence).toBe(0.6); + expect(result.reasons).toHaveLength(1); + expect(result.reasons[0]).toMatch(/message/i); + }); + + it('deducts 0.3 and names the reason when blast-radius is unavailable (confidence deduction)', () => { + const result = computeConfidence({ + hasAnyMessage: true, + blastRadiusStatus: 'unavailable', + hasAttachmentOrUrlIndicators: true, + }); + expect(result.confidence).toBe(0.7); + expect(result.reasons[0]).toMatch(/mimecast|blast/i); + }); + + it('deducts 0.2 and names the reason when no attachment/url indicators are found (confidence deduction)', () => { + const result = computeConfidence({ + hasAnyMessage: true, + blastRadiusStatus: 'ok', + hasAttachmentOrUrlIndicators: false, + }); + expect(result.confidence).toBe(0.8); + expect(result.reasons[0]).toMatch(/indicator/i); + }); + + it('floors at 0.10 with all three named reasons when every evidence source is missing (confidence deduction)', () => { + const result = computeConfidence({ + hasAnyMessage: false, + blastRadiusStatus: 'unavailable', + hasAttachmentOrUrlIndicators: false, + }); + expect(result.confidence).toBe(0.1); + expect(result.reasons).toHaveLength(3); + }); +}); + +describe('mapVerdictToActions', () => { + it('maps SPAM to no_action', () => { + expect(mapVerdictToActions('SPAM', { clicked: 0 })).toEqual(['no_action']); + }); + + it('maps UNWANTED to warn_user', () => { + expect(mapVerdictToActions('UNWANTED', { clicked: 0 })).toEqual(['warn_user']); + }); + + it('maps THREAT with no clicks to block_sender + purge_message', () => { + expect(mapVerdictToActions('THREAT', { clicked: 0 })).toEqual(['block_sender', 'purge_message']); + }); + + it('maps THREAT with clicks to also include reset_password/isolate_endpoint/disable_forwarding_rule', () => { + const actions = mapVerdictToActions('THREAT', { clicked: 1 }); + expect(actions).toContain('block_sender'); + expect(actions).toContain('purge_message'); + expect(actions).toContain('reset_password'); + expect(actions).toContain('isolate_endpoint'); + expect(actions).toContain('disable_forwarding_rule'); + }); +}); + +describe('computeRequiresApproval', () => { + it('is false for disable_forwarding_rule alone (requires_approval invariant)', () => { + expect(computeRequiresApproval(['disable_forwarding_rule'])).toBe(false); + }); + + it('is true when disable_forwarding_rule is combined with a destructive action (requires_approval invariant)', () => { + expect(computeRequiresApproval(['disable_forwarding_rule', 'block_sender'])).toBe(true); + }); + + it.each(['block_sender', 'purge_message', 'reset_password', 'isolate_endpoint'])( + 'is true for %s alone (requires_approval invariant)', + (action) => { + expect(computeRequiresApproval([action])).toBe(true); + } + ); + + it('is false for no_action and warn_user (requires_approval invariant)', () => { + expect(computeRequiresApproval(['no_action'])).toBe(false); + expect(computeRequiresApproval(['warn_user'])).toBe(false); + }); +});