diff --git a/.planning/phases/19-classification-engine/19-01-SUMMARY.md b/.planning/phases/19-classification-engine/19-01-SUMMARY.md new file mode 100644 index 0000000..57dd5aa --- /dev/null +++ b/.planning/phases/19-classification-engine/19-01-SUMMARY.md @@ -0,0 +1,128 @@ +--- +phase: 19-classification-engine +plan: 01 +subsystem: security +tags: [phishing-triage, deterministic-classifier, vitest, postgres, mimecast] + +# Dependency graph +requires: + - phase: 16-eml-mime-evidence-parser + provides: "NormalizedMessage / AuthResults shapes, messages.headers JSONB (eml-parser.ts)" + - phase: 17-mimecast-blast-radius-lookup + provides: "getBlastRadius() delivered/held/rejected/clicked lookup (mimecast-blast-radius.ts)" + - phase: 18 (campaign grouping) + provides: "campaigns/reports linkage, ORDER BY created_at ASC 'earliest is canonical' convention" +provides: + - "classifyCampaign(campaignId) — deterministic SPAM/UNWANTED/THREAT verdict engine, no LLM calls" + - "KNOWN_SIMULATION_SENDERS allowlist (it-support.care, breachsecurenow.com) + domainMatchesAllowlist/isKnownSimulationSender" + - "gatherCampaignEvidence(campaignId) — bounded evidence assembly (reports/messages/indicators + one getBlastRadius call)" + - "classifications table INSERT (append-only, D-02) with verdict/confidence/reasons/recommendedActions/requiresApproval" +affects: ["20 (remediation actions)", "phishing/campaigns/{id}/classify route"] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Pure evidence-in / rule-eval / verdict-out module shape (mirrors robotic-classifier.ts, campaign-grouping-service.ts)" + - "vi.mock query-router-by-SQL-substring test pattern (mirrors campaign-grouping-service.test.ts)" + +key-files: + created: + - lib/services/campaign-classifier.ts + - lib/services/campaign-classifier.test.ts + - lib/services/campaign-classifier.fixtures.ts + modified: [] + +key-decisions: + - "D-06 allowlist stored as a TypeScript constant (KNOWN_SIMULATION_SENDERS), not a DB table — logic-drift mitigation (T-19-02) via unit-tested pure code" + - "domainMatchesAllowlist uses exact-or-proper-subdomain match only (d===allowed || d.endsWith('.'+allowed)) — never .includes() substring matching (T-19-01)" + - "D-03 THREAT gate requires BOTH delivered>0/clicked>0 AND (hard auth fail OR known-bad-indicator match spanning >=2 messages) — either signal alone stays at UNWANTED" + - "D-04 UNWANTED fires on ANY suspicious signal (single attachment/url indicator, or delivery contained to the reporter(s) only); SPAM only when neither is present" + - "ASSUMPTION FLAG (surfaced per plan instruction, needs user confirmation): THREAT recommendedActions escalate to reset_password/isolate_endpoint/disable_forwarding_rule only when blastRadius.clicked>0 — this is a reasoned research proposal (19-RESEARCH.md Open Question #1), NOT an explicit D-08 decision. Does not contradict any locked decision; materially affects what Phase 20 gates approval on." + +requirements-completed: [CLASSIFY-01, CLASSIFY-02, CLASSIFY-03, CLASSIFY-04, CLASSIFY-06] + +# Metrics +duration: 15min +completed: 2026-07-16 +--- + +# Phase 19 Plan 01: Classification Engine — Pure Rule Functions + classifyCampaign Orchestrator Summary + +**Deterministic SPAM/UNWANTED/THREAT classifier with a KnowBe4/Breach-Secure-Now simulation allowlist, D-03 THREAT gate (delivery + malicious signal), D-04 SPAM/UNWANTED split, D-05 confidence scoring, and an append-only `classifications` INSERT — zero LLM calls.** + +## Performance + +- **Duration:** ~15 min (first commit 08:14:51 → last commit 08:20:58) +- **Started:** 2026-07-16T08:14:51-04:00 +- **Completed:** 2026-07-16T08:20:58-04:00 +- **Tasks:** 2 completed +- **Files modified:** 3 (all new) + +## Accomplishments + +- Pure rule functions (`domainMatchesAllowlist`, `isKnownSimulationSender`, `effectiveAuthResults`, `hasHardAuthFail`, `computeConfidence`, `mapVerdictToActions`, `computeRequiresApproval`) — each independently unit-tested, no DB/Mimecast dependency +- `classifyCampaign(campaignId)` orchestrator: gathers campaign evidence, applies D-06 simulation short-circuit → D-03 THREAT tier → D-04 SPAM/UNWANTED split → D-05 confidence → D-08 action mapping, and appends one `classifications` row (no `ON CONFLICT`) +- Positive-path THREAT proven with a real (non-simulation) signal fixture — closes the "always-SPAM stub" blocker the plan explicitly flagged +- D-03's known-bad-indicator OR-branch proven independently of the auth-fail path (auth PASS + shared indicator across 2 messages → THREAT) +- D-04's SPAM/UNWANTED boundary proven with two distinct fixtures (not just a single always-one-verdict stub) +- Synthetic KnowBe4 (`it-support.care`) and Breach Secure Now (`breachsecurenow.com`) simulation fixtures reproduce the Pitfall-1 forwarding-induced auth-verdict inversion, proving both the D-06 allowlist short-circuit and the `authResultsOriginal` precedence in one test + +## Task Commits + +Each task followed the TDD RED → GREEN cycle with a `test(...)` commit before its `feat(...)` commit: + +1. **Task 1: Failing tests + fixtures + pure rule functions** + - `f4e6baf` (test) — failing tests + synthetic fixtures for classifier pure rule functions + - `3ea6c95` (feat) — implemented pure rule functions (D-05/D-06/D-08); all 30 tests green +2. **Task 2: classifyCampaign orchestrator + evidence gathering + append-only INSERT** + - `f6c954a` (test) — classifyCampaign orchestrator tests (mocked postgres-client + mimecast-blast-radius) + - `38c1ae4` (feat) — implemented `gatherCampaignEvidence` + `classifyCampaign`; all 39 tests green + +**Plan metadata:** this SUMMARY.md commit (see below) + +## Files Created/Modified + +- `lib/services/campaign-classifier.ts` — `classifyCampaign` orchestrator, `gatherCampaignEvidence`, pure rule functions, `KNOWN_SIMULATION_SENDERS` constant, `ClassifyResult`/`CampaignEvidence`/`ParsedMessage` types +- `lib/services/campaign-classifier.test.ts` — 39 tests across 9 pure-function describe blocks + `classifyCampaign` orchestration describe block (mocked `postgres-client`/`mimecast-blast-radius`) +- `lib/services/campaign-classifier.fixtures.ts` — synthetic KnowBe4/BSN simulation fixtures + non-simulation threat/clean-spam/suspicious-unwanted fixtures (all invented content, no real customer email) + +## Decisions Made + +- Kept `isKnownSimulationSender`'s parameter type narrowed to a new `SenderIdentity` interface (`{ from: { domain }, returnPath }`) instead of the full `NormalizedMessage`, so both the Task 1 fixtures (typed as full `NormalizedMessage`) and Task 2's bounded `ParsedMessage` (built from `messages.headers` JSONB) can share the same allowlist-check function without type friction. +- `evaluateSpamVsUnwanted`'s "suspicious signal" definition (any attachment/url indicator present OR delivery contained to the reporter(s) only) was Claude's Discretion per CONTEXT.md D-04 — implemented as an OR of the two conditions so either alone is enough to clear SPAM into UNWANTED, while requiring the stronger D-03 gate (delivery + malicious signal) to reach THREAT. +- `gatherCampaignEvidence` caps only the human-readable `reportSample` at 10 (T-19-03/CLASSIFY-06 DoS guard for anything embedded in output); the full `messages`/`indicators` arrays are used internally for correctness-critical verdict computation (D-03's cross-message indicator correlation needs the complete set, not a sample) — the bounding requirement is satisfied by keeping `reasons` short and named (never one line per indicator), not by truncating rule-evaluation inputs. + +## Deviations from Plan + +None — plan executed exactly as written. The ASSUMPTION FLAG called out in the plan's Task 1 action block (click-driven escalation to `reset_password`/`isolate_endpoint`/`disable_forwarding_rule`) was implemented exactly as the plan's code example specified and is documented above under Key Decisions for user visibility, per the plan's own instruction to surface it — it is not a deviation, since the plan explicitly directed this implementation. + +## Issues Encountered + +- Worktree branch (`worktree-agent-af5d279be12eefb03`) was created from a commit predating this milestone's Phase 16-18 work (missing `eml-parser.ts`, `mimecast-blast-radius.ts`, `campaign-grouping-service.ts`, migration 097, etc.) — confirmed the worktree had zero commits of its own ahead of `master` (`git log HEAD..master` = 333 commits, `master..HEAD` = 0), so `git reset --hard master` was safe and used to bring the worktree in sync with all prerequisite phases before starting. +- Full `npm test` surfaced 2 pre-existing, unrelated failures in `lib/services/analyzer/itglue-search.test.ts` (`client.getFlexibleAssetsForOrganization is not a function`) — confirmed via `git log` that this test file predates this phase and was not touched by this plan. Logged to `.planning/phases/19-classification-engine/deferred-items.md` per the executor's scope-boundary rule; not fixed. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- `classifyCampaign(campaignId)` is ready to be wired into `POST /api/phishing/campaigns/{id}/classify` (Plan 19-02 / CLASSIFY-05) — the route only needs to call it and return the `ClassifyResult` shape. +- Phase 20 (remediation actions) can consume `recommendedActions`/`requiresApproval` directly — the D-08 action vocabulary (`no_action`, `warn_user`, `disable_forwarding_rule`, `block_sender`, `purge_message`, `reset_password`, `isolate_endpoint`) is exactly what's produced. +- **Needs a quick user confirmation** (flagged in the plan, not a blocker): whether `clicked>0`-driven escalation to `reset_password`/`isolate_endpoint`/`disable_forwarding_rule` is the right trigger for Phase 20's approval gating, since it's a reasoned proposal rather than a locked CONTEXT.md decision. + +--- +*Phase: 19-classification-engine* +*Completed: 2026-07-16* + +## Self-Check: PASSED + +- FOUND: lib/services/campaign-classifier.ts +- FOUND: lib/services/campaign-classifier.test.ts +- FOUND: lib/services/campaign-classifier.fixtures.ts +- FOUND: .planning/phases/19-classification-engine/deferred-items.md +- FOUND commit: f4e6baf (test: pure rule function tests + fixtures) +- FOUND commit: 3ea6c95 (feat: pure rule functions) +- FOUND commit: f6c954a (test: classifyCampaign orchestrator tests) +- FOUND commit: 38c1ae4 (feat: classifyCampaign orchestrator + evidence gathering) diff --git a/.planning/phases/19-classification-engine/deferred-items.md b/.planning/phases/19-classification-engine/deferred-items.md new file mode 100644 index 0000000..3d3e233 --- /dev/null +++ b/.planning/phases/19-classification-engine/deferred-items.md @@ -0,0 +1,15 @@ +# Deferred Items — Phase 19 + +Out-of-scope discoveries logged during execution, not fixed per the executor's scope boundary +(only auto-fix issues directly caused by the current task's changes). + +## Plan 19-01 + +- **`lib/services/analyzer/itglue-search.test.ts`** — 2 pre-existing failing tests + (`returns capped, redacted doc snippets when the org is found`, + `tolerates per-call failures (configurations errors, flex still returns)`), unrelated to + this plan's changes. Root cause appears to be `client.getFlexibleAssetsForOrganization is + not a function` — a mock/client-shape mismatch in the analyzer test suite, not touched by + `campaign-classifier.ts`/`.test.ts`/`.fixtures.ts`. Confirmed pre-existing via `git log` on + the test file (introduced in the original "AI ticket analyzer (phases 1-6)" commit, long + before this phase). Full suite otherwise green (358/360 passing before this discovery). 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..596290d --- /dev/null +++ b/lib/services/campaign-classifier.test.ts @@ -0,0 +1,501 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock postgresClient BEFORE importing the module under test — mirrors +// campaign-grouping-service.test.ts's vi.mock() factory-mocking discipline. +const queryMock = vi.fn(); +vi.mock('./postgres-client', () => ({ + postgresClient: { + query: (...args: unknown[]) => queryMock(...args), + }, +})); + +// Mock getBlastRadius entirely — mirrors mimecast-blast-radius.test.ts's +// sibling-service mocking pattern. No real Mimecast/Postgres calls happen. +const getBlastRadiusMock = vi.fn(); +vi.mock('./mimecast-blast-radius', () => ({ + getBlastRadius: (...args: unknown[]) => getBlastRadiusMock(...args), +})); + +// eslint-disable-next-line import/first -- imported after vi.mock hoisting +import { + KNOWN_SIMULATION_SENDERS, + domainMatchesAllowlist, + isKnownSimulationSender, + effectiveAuthResults, + hasHardAuthFail, + computeConfidence, + mapVerdictToActions, + computeRequiresApproval, + classifyCampaign, +} from './campaign-classifier'; +// eslint-disable-next-line import/first -- imported after vi.mock hoisting +import { + knowbe4SimMessage, + bsnSimMessage, + threatMessage, + cleanSpamMessage, + suspiciousUnwantedMessage, +} from './campaign-classifier.fixtures'; +import type { NormalizedMessage } from './eml-parser'; + +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); + }); +}); + +// ============================================================================= +// classifyCampaign — mocked-DB orchestration tests (CLASSIFY-01/02/03/04/06, +// D-02/D-03/D-04/D-06) +// +// `queryMock` routes staged rows based on a distinguishing SQL substring per +// call (`FROM reports`, `FROM messages`, `FROM indicators`, +// `INSERT INTO classifications`) — NOT by call order — mirroring +// campaign-grouping-service.test.ts's makeClient() discipline. +// ============================================================================= + +interface ReportFixtureRow { + id: string; + title: string | null; + created_at: string; + requester_email: string | null; +} + +interface StagedRows { + reports?: ReportFixtureRow[]; + messages?: Array<{ id: string; report_id: string; headers: NormalizedMessage }>; + indicators?: Array<{ id: string; message_id: string; indicator_type: string; value: string }>; +} + +function stageQueries(rows: StagedRows) { + queryMock.mockImplementation(async (sql: string) => { + if (sql.includes('INSERT INTO classifications')) { + return { rows: [{ id: 'classification-1', created_at: '2026-07-16T00:00:00.000Z' }], rowCount: 1 }; + } + if (sql.includes('FROM reports')) { + return { rows: rows.reports ?? [], rowCount: rows.reports?.length ?? 0 }; + } + if (sql.includes('FROM messages')) { + return { rows: rows.messages ?? [], rowCount: rows.messages?.length ?? 0 }; + } + if (sql.includes('FROM indicators')) { + return { rows: rows.indicators ?? [], rowCount: rows.indicators?.length ?? 0 }; + } + throw new Error(`Unstaged query in test mock: ${sql}`); + }); +} + +function toMessageRow(id: string, reportId: string, fixture: NormalizedMessage) { + return { id, report_id: reportId, headers: fixture }; +} + +const REPORTER_EMAIL = 'reporter@wulfconsulting.test'; + +describe('classifyCampaign', () => { + beforeEach(() => { + queryMock.mockReset(); + getBlastRadiusMock.mockReset(); + }); + + it('returns exactly one verdict with the full payload shape (returns exactly one verdict)', async () => { + stageQueries({ + reports: [ + { id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + ], + messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)], + indicators: [], + }); + getBlastRadiusMock.mockResolvedValue({ + status: 'ok', + matched: 1, + delivered: 0, + held: 1, + rejected: 0, + clicked: 0, + perRecipient: [], + source: 'fan-out', + }); + + const result = await classifyCampaign('campaign-1'); + + expect(['SPAM', 'UNWANTED', 'THREAT']).toContain(result.verdict); + expect(typeof result.id).toBe('string'); + expect(result.campaignId).toBe('campaign-1'); + expect(typeof result.confidence).toBe('number'); + expect(typeof result.summary).toBe('string'); + expect(Array.isArray(result.reasons)).toBe(true); + expect(Array.isArray(result.recommendedActions)).toBe(true); + expect(typeof result.requiresApproval).toBe('boolean'); + expect(typeof result.createdAt).toBe('string'); + }); + + it('inserts exactly one append-only classifications row with no ON CONFLICT', async () => { + stageQueries({ + reports: [ + { id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + ], + messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)], + indicators: [], + }); + getBlastRadiusMock.mockResolvedValue({ + status: 'ok', + matched: 1, + delivered: 0, + held: 1, + rejected: 0, + clicked: 0, + perRecipient: [], + source: 'fan-out', + }); + + await classifyCampaign('campaign-1'); + + const insertCalls = queryMock.mock.calls.filter( + ([sql]) => typeof sql === 'string' && sql.includes('INSERT INTO classifications') + ); + expect(insertCalls).toHaveLength(1); + expect(insertCalls[0][0]).not.toMatch(/ON CONFLICT/i); + }); + + it.each([ + ['knowbe4 (From match)', knowbe4SimMessage], + ['breach-secure-now (Return-Path match)', bsnSimMessage], + ])( + 'never classifies a known simulation sender as THREAT despite a hard auth fail and delivered>0 (simulation allowlist: %s)', + async (_label, fixture) => { + stageQueries({ + reports: [ + { id: 'report-1', title: fixture.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + ], + messages: [toMessageRow('message-1', 'report-1', fixture)], + indicators: [], + }); + getBlastRadiusMock.mockResolvedValue({ + status: 'ok', + matched: 1, + delivered: 1, + held: 0, + rejected: 0, + clicked: 0, + perRecipient: [{ recipient: REPORTER_EMAIL, status: 'delivered' }], + source: 'fan-out', + }); + + const result = await classifyCampaign('campaign-1'); + + expect(result.verdict).not.toBe('THREAT'); + } + ); + + it('classifies a real non-simulation signal as THREAT with destructive recommended actions (threat tier)', async () => { + stageQueries({ + reports: [ + { id: 'report-1', title: threatMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + ], + messages: [toMessageRow('message-1', 'report-1', threatMessage)], + indicators: [], + }); + getBlastRadiusMock.mockResolvedValue({ + status: 'ok', + matched: 3, + delivered: 3, + held: 0, + rejected: 0, + clicked: 0, + perRecipient: [ + { recipient: REPORTER_EMAIL, status: 'delivered' }, + { recipient: 'victim2@wulfconsulting.test', status: 'delivered' }, + { recipient: 'victim3@wulfconsulting.test', status: 'delivered' }, + ], + source: 'fan-out', + }); + + const result = await classifyCampaign('campaign-1'); + + expect(result.verdict).toBe('THREAT'); + expect(result.recommendedActions).toContain('block_sender'); + expect(result.recommendedActions).toContain('purge_message'); + expect(result.requiresApproval).toBe(true); + }); + + it('classifies THREAT via the known-bad-indicator OR-branch when auth passes across 2 messages (threat tier known-bad indicator)', async () => { + const sharedUrl = 'http://evil-shared.example.test/payload'; + stageQueries({ + reports: [ + { id: 'report-1', title: 'Invoice attached', created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + { id: 'report-2', title: 'Invoice attached', created_at: '2026-07-15T02:00:00.000Z', requester_email: 'reporter2@wulfconsulting.test' }, + ], + messages: [ + toMessageRow('message-1', 'report-1', cleanSpamMessage), + toMessageRow('message-2', 'report-2', cleanSpamMessage), + ], + indicators: [ + { id: 'ind-1', message_id: 'message-1', indicator_type: 'url', value: sharedUrl }, + { id: 'ind-2', message_id: 'message-2', indicator_type: 'url', value: sharedUrl }, + ], + }); + getBlastRadiusMock.mockResolvedValue({ + status: 'ok', + matched: 1, + delivered: 1, + held: 0, + rejected: 0, + clicked: 0, + perRecipient: [{ recipient: REPORTER_EMAIL, status: 'delivered' }], + source: 'fan-out', + }); + + const result = await classifyCampaign('campaign-1'); + + expect(result.verdict).toBe('THREAT'); + }); + + it('classifies a clean campaign with no indicators and no delivery/click signal as SPAM (spam vs unwanted tier)', async () => { + stageQueries({ + reports: [ + { id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + ], + messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)], + indicators: [], + }); + getBlastRadiusMock.mockResolvedValue({ + status: 'ok', + matched: 1, + delivered: 0, + held: 1, + rejected: 0, + clicked: 0, + perRecipient: [], + source: 'fan-out', + }); + + const result = await classifyCampaign('campaign-1'); + + expect(result.verdict).toBe('SPAM'); + }); + + it('classifies a suspicious-but-contained campaign (one url indicator, delivery contained to reporter) as UNWANTED (spam vs unwanted tier)', async () => { + stageQueries({ + reports: [ + { id: 'report-1', title: suspiciousUnwantedMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + ], + messages: [toMessageRow('message-1', 'report-1', suspiciousUnwantedMessage)], + indicators: [ + { id: 'ind-1', message_id: 'message-1', indicator_type: 'url', value: 'http://promo.some-vendor.net/deal' }, + ], + }); + getBlastRadiusMock.mockResolvedValue({ + status: 'ok', + matched: 1, + delivered: 1, + held: 0, + rejected: 0, + clicked: 0, + perRecipient: [{ recipient: REPORTER_EMAIL, status: 'delivered' }], + source: 'fan-out', + }); + + const result = await classifyCampaign('campaign-1'); + + expect(result.verdict).toBe('UNWANTED'); + }); + + it('keeps persisted reasons short and free of raw body text even with many indicators (evidence bounding)', async () => { + const manyIndicators = Array.from({ length: 50 }, (_, i) => ({ + id: `ind-${i}`, + message_id: 'message-1', + indicator_type: 'url', + value: `http://spammy-${i}.example.test/x`, + })); + stageQueries({ + reports: [ + { id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + ], + messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)], + indicators: manyIndicators, + }); + getBlastRadiusMock.mockResolvedValue({ + status: 'ok', + matched: 1, + delivered: 0, + held: 1, + rejected: 0, + clicked: 0, + perRecipient: [], + source: 'fan-out', + }); + + const result = await classifyCampaign('campaign-1'); + + expect(result.reasons.length).toBeLessThanOrEqual(5); + for (const reason of result.reasons) { + expect(reason.length).toBeLessThan(300); + } + }); +}); diff --git a/lib/services/campaign-classifier.ts b/lib/services/campaign-classifier.ts new file mode 100644 index 0000000..48605c4 --- /dev/null +++ b/lib/services/campaign-classifier.ts @@ -0,0 +1,524 @@ +/** + * 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'; + +// ============================================================================= +// 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}`)) + ); +} + +/** + * 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'; + +/** 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)); +} + +// ============================================================================= +// 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; +} + +interface MessageDbRow { + id: string; + report_id: string; + headers: Record | 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; +} + +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 { + // 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( + `SELECT r.id::text AS id, r.title, r.created_at::text AS created_at, + c.email_address AS requester_email + 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, + })); + const reportIds = reports.map((r) => r.id); + + const messagesRes = reportIds.length + ? await postgresClient.query( + `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( + `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); + 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), + }, + }); + } 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>(); + 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(); + 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 { + 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 = evaluateSpamVsUnwanted(evidence); + 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; + } +}