wulf-pulse/.planning/phases/19-classification-engine/19-01-PLAN.md

23 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
19-classification-engine 01 tdd 1
lib/services/campaign-classifier.ts
lib/services/campaign-classifier.test.ts
lib/services/campaign-classifier.fixtures.ts
true
CLASSIFY-01
CLASSIFY-02
CLASSIFY-03
CLASSIFY-04
CLASSIFY-06
truths artifacts key_links
classifyCampaign(campaignId) returns exactly one of SPAM/UNWANTED/THREAT with confidence, summary, reasons[], recommendedActions[], requiresApproval
Any recommendedActions entry in the destructive set (block_sender, purge_message, reset_password, isolate_endpoint) forces requiresApproval:true; disable_forwarding_rule alone does NOT
Incomplete evidence lowers confidence below 1.0 and names each specific missing source in reasons
A message whose From/Return-Path domain matches KNOWN_SIMULATION_SENDERS is never classified THREAT even when authResults shows a hard fail
THREAT-tier auth-fail check reads authResultsOriginal first, falling back to authResults only when null
The classifier reads structured columns/headers only — no raw unbounded email body reaches the verdict logic, reasons, or persisted row
path provides exports min_lines
lib/services/campaign-classifier.ts classifyCampaign orchestrator, gatherCampaignEvidence, pure rule functions, KNOWN_SIMULATION_SENDERS constant, ClassifyResult type
classifyCampaign
KNOWN_SIMULATION_SENDERS
ClassifyResult
200
path provides contains
lib/services/campaign-classifier.test.ts Unit + mocked-integration coverage for CLASSIFY-01/02/03/04/06 requires_approval invariant
path provides contains
lib/services/campaign-classifier.fixtures.ts Synthetic KnowBe4 (it-support.care) and BSN (breachsecurenow.com) NormalizedMessage fixtures with authResultsOriginal=pass / authResults=fail it-support.care
from to via pattern
lib/services/campaign-classifier.ts getBlastRadius import from ./mimecast-blast-radius getBlastRadius
from to via pattern
lib/services/campaign-classifier.ts classifications table postgresClient INSERT (append-only, no ON CONFLICT) INSERT INTO classifications
from to via pattern
lib/services/campaign-classifier.ts messages.headers.authResultsOriginal effectiveAuthResults precedence authResultsOriginal
Build `lib/services/campaign-classifier.ts` — a pure, deterministic SPAM/UNWANTED/THREAT rule engine over bounded structured phishing-triage evidence, plus its full vitest suite and synthetic fixtures. No LLM/Anthropic/OpenRouter calls (D-01). One exported orchestrator `classifyCampaign(campaignId)` that gathers campaign evidence, applies the D-06 → D-03 → D-04 rule order, computes D-05 confidence, maps D-08 actions, and INSERTs one append-only `classifications` row.

Purpose: Every campaign gets a deterministic, self-explaining verdict that flags destructive recommendations for approval and never cries wolf on KnowBe4/BSN simulations.

Output: campaign-classifier.ts, campaign-classifier.test.ts, campaign-classifier.fixtures.ts.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/19-classification-engine/19-CONTEXT.md @.planning/phases/19-classification-engine/19-RESEARCH.md @.planning/phases/19-classification-engine/19-PATTERNS.md @.planning/phases/19-classification-engine/19-VALIDATION.md

From lib/services/eml-parser.ts:

export type AuthVerdict = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
export interface AuthResults { spf?: AuthVerdict; dkim?: AuthVerdict; dmarc?: AuthVerdict; }
export interface NormalizedMessage {
  from: { displayName: string | null; email: string | null; domain: string | null };
  returnPath: string | null;
  // subject, urls, attachments, ... plus:
  authResults: AuthResults;              // primary Authentication-Results header (post-forward)
  authResultsOriginal: AuthResults | null; // Authentication-Results-Original (pre-forward), may be null
}

NOTE: in the DB, the full NormalizedMessage header object is stored in messages.headers JSONB. headers->>'subject' gives subject; headers->'from'->>'domain', headers->>'returnPath', headers->'authResults', headers->'authResultsOriginal' give the rest.

From lib/services/mimecast-blast-radius.ts:

export async function getBlastRadius(input: BlastRadiusInput): Promise<BlastRadiusResult>;
export type BlastRadiusResult =
  | { status: 'unavailable'; reason: 'not_configured' | 'lookup_failed'; error?: string }
  | { status: 'ok'; matched: number; delivered: number; held: number; rejected: number;
      clicked: number; perRecipient: Array<{ recipient: string; status: 'delivered'|'held'|'rejected'|'unknown' }>; source: 'fan-out' };
// BlastRadiusInput = { sender, recipient, subject, dateWindow: { start: Date; end: Date } }

classifications table (migrations/097_phishing_triage_schema.sql):

classifications (id UUID PK, campaign_id UUID, verdict TEXT, confidence NUMERIC,
  summary TEXT, reasons JSONB, recommended_actions JSONB,
  requires_approval BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ)

messages/indicators/reports/campaigns columns:

messages    (id, report_id, message_id, headers JSONB, urls JSONB, attachments JSONB, body_preview, raw_ref, created_at)
indicators  (id, message_id, indicator_type TEXT, value TEXT, created_at)  -- indicator_type ∈ 'attachment_hash'|'url'|'sender'
reports     (id, ticket_id, ticket_number, company_name, title, requester_contact_id, campaign_id, created_at)
campaigns   (id, campaign_key, group_method, first_seen_at, last_seen_at, report_count, status, created_at, updated_at)
Task 1: Failing tests + fixtures + pure rule functions (RED→GREEN) lib/services/campaign-classifier.test.ts, lib/services/campaign-classifier.fixtures.ts, lib/services/campaign-classifier.ts - lib/services/robotic-classifier.ts (evaluateContains: .toLowerCase().includes() only, no regex/eval — the D-01 rule-eval style to mirror) - lib/services/phishing-detector.ts (KNOWN_PHISHING_PATTERNS: the locked readonly TS-constant shape to mirror for KNOWN_SIMULATION_SENDERS — do NOT reuse/extend that constant, it is a different signal) - lib/services/eml-parser.ts (AuthResults, AuthVerdict, NormalizedMessage exact shapes) - lib/services/mimecast-blast-radius.ts (BlastRadiusResult discriminated union — the evidence-completeness precedent) - lib/services/campaign-grouping-service.test.ts (vi.mock('./postgres-client') factory + eslint-disable import/first hoisting discipline) - lib/services/mimecast-blast-radius.test.ts (vi.mock of a sibling service module — pattern for mocking getBlastRadius) - lib/services/eml-parser.fixtures.ts (synthetic-fixture convention — no real customer data) - .planning/phases/19-classification-engine/19-RESEARCH.md (Code Examples: confidence weights, action mapping, allowlist, auth precedence) - computeRequiresApproval(['disable_forwarding_rule']) === false (non-destructive alone) - computeRequiresApproval(['disable_forwarding_rule','block_sender']) === true (OR'd across actions) - computeRequiresApproval(['block_sender']) / ['purge_message'] / ['reset_password'] / ['isolate_endpoint'] each === true - computeRequiresApproval(['no_action']) === false; (['warn_user']) === false - domainMatchesAllowlist('it-support.care') === true; ('sub.it-support.care') === true; ('em8721.breachsecurenow.com') === true (proper subdomain suffix) - domainMatchesAllowlist('it-support.care.attacker.net') === false; ('evil-it-support.care') === false (no bare substring match — T-19-01) - isKnownSimulationSender matches when From.domain is null but Return-Path domain matches (Pitfall 3 — check BOTH) - effectiveAuthResults returns authResultsOriginal when present, authResults when authResultsOriginal is null (Pitfall 1) - hasHardAuthFail true iff spf==='fail' OR dkim==='fail' OR dmarc==='fail' (never on 'none'/'neutral'/undefined) - computeConfidence: baseline 1.0; -0.4 no message parsed; -0.3 blastRadius.status!=='ok'; -0.2 no attachment/url indicators; each deduction pushes a named reason; all-missing floors at 0.10; rounds to 2 decimals - mapVerdictToActions('SPAM',_)===['no_action']; ('UNWANTED',_)===['warn_user']; ('THREAT',{clicked:0})===['block_sender','purge_message']; ('THREAT',{clicked:1}) additionally includes 'reset_password','isolate_endpoint','disable_forwarding_rule' Write `campaign-classifier.test.ts` FIRST (RED), then `campaign-classifier.fixtures.ts`, then implement the pure functions in `campaign-classifier.ts` until green (GREEN).
Fixtures (`campaign-classifier.fixtures.ts`, synthetic only — no real customer email): export a KnowBe4 fixture message with `from.domain='it-support.care'` and a BSN fixture with `from.domain=null, returnPath='bounces...@em8721.breachsecurenow.com'`. EACH fixture sets `authResults = { spf:'fail', dkim:'fail', dmarc:'fail' }` but `authResultsOriginal = { spf:'pass', dkim:'pass', dmarc:'pass' }` — this reproduces the forwarding-induced auth-verdict inversion (Pitfall 1) so the simulation-not-THREAT test proves both the allowlist short-circuit AND the authResultsOriginal precedence.

Implement in `campaign-classifier.ts` as bare exported functions (no class, no getInstance — mirror groupReportIntoCampaign's module shape). Use the named import `import { postgresClient } from './postgres-client';` and sibling imports `import { getBlastRadius, type BlastRadiusResult } from './mimecast-blast-radius';` / `import type { NormalizedMessage, AuthResults } from './eml-parser';`.

Constants and functions to define:
- `export const KNOWN_SIMULATION_SENDERS: readonly { vendor: string; domains: readonly string[] }[]` seeded with `{ vendor:'knowbe4', domains:['it-support.care'] }` and `{ vendor:'breach-secure-now', domains:['breachsecurenow.com'] }`. Include a doc-comment citing 19-RESEARCH.md D-07 findings and stating the list is NOT exhaustive and should be refreshed from new ticket evidence (Pitfall 4).
- `domainMatchesAllowlist(domain)`: lowercases, matches `d === allowed || d.endsWith('.' + allowed)` ONLY — never `.includes()` (T-19-01 spoofing guard).
- `isKnownSimulationSender(msg)`: collect `msg.from.domain` and the domain-part of `msg.returnPath` (local `.split('@')[1] ?? null`), filter nulls, return true if any matches the allowlist (Pitfall 3).
- `effectiveAuthResults(headers)`: `return headers.authResultsOriginal ?? headers.authResults` (Pitfall 1).
- `hasHardAuthFail(auth)`: `auth.spf==='fail' || auth.dkim==='fail' || auth.dmarc==='fail'`.
- `computeConfidence(flags)`: additive-from-1.0 per D-05 with weights 0.4 / 0.3 / 0.2, each named in reasons, `Math.round(c*100)/100`.
- `const DESTRUCTIVE_ACTIONS = new Set(['block_sender','purge_message','reset_password','isolate_endpoint'])`.
- `mapVerdictToActions(verdict, evidence)` per the behavior block. `computeRequiresApproval(actions)` = `actions.some(a => DESTRUCTIVE_ACTIONS.has(a))`.

Each pure function gets its own `describe` block with direct input/output assertions (no DB/Mimecast mocks needed for these). Do NOT implement `classifyCampaign`/`gatherCampaignEvidence` yet — Task 2 covers those; leave stubs or omit until then.
npx vitest run lib/services/campaign-classifier.test.ts -t "requires_approval invariant" npx vitest run lib/services/campaign-classifier.test.ts -t "confidence deduction" npx tsc --noEmit --pretty - `npx vitest run lib/services/campaign-classifier.test.ts` passes for all pure-function describe blocks - Test asserts `computeRequiresApproval(['disable_forwarding_rule']) === false` AND `computeRequiresApproval(['disable_forwarding_rule','block_sender']) === true` (CLASSIFY-02 invariant + counter-case) - Test asserts `domainMatchesAllowlist('it-support.care.attacker.net') === false` (T-19-01 no-substring-match) - Test asserts `effectiveAuthResults` returns the `authResultsOriginal` object when present (Pitfall 1) - Test asserts all-three-missing confidence === 0.10 with 3 named reason strings (CLASSIFY-03) - `grep -n "KNOWN_SIMULATION_SENDERS" lib/services/campaign-classifier.ts` shows the constant with both `it-support.care` and `breachsecurenow.com` - `grep -c "\.includes(" lib/services/campaign-classifier.ts` shows domain matching does NOT use `.includes(` for allowlist comparison (endsWith only) - `npx tsc --noEmit --pretty` exits 0 All pure rule functions implemented and unit-tested green; fixtures exist with the authResultsOriginal-pass/authResults-fail inversion; tsc clean. Task 2: classifyCampaign orchestrator + evidence gathering + append-only INSERT (RED→GREEN) lib/services/campaign-classifier.ts, lib/services/campaign-classifier.test.ts - lib/services/campaign-classifier.ts (current state from Task 1 — pure functions to compose) - lib/services/campaign-grouping-service.ts (evidence gather + ORDER BY r.created_at ASC "earliest report is canonical" convention; catch-log-rethrow orchestrator boundary; [CAMPAIGN-GROUPING] log prefix style) - app/api/phishing/campaigns/[id]/route.ts (bulk-fetch-by-id-array shape: reportIds → messages via report_id=ANY, messageIds → indicators via message_id=ANY; headers->>'subject') - migrations/097_phishing_triage_schema.sql (classifications INSERT columns; messages/indicators/reports/campaigns columns) - lib/services/eml-parser.ts (NormalizedMessage header shape stored in messages.headers JSONB) - lib/services/mimecast-blast-radius.ts (getBlastRadius input shape) - .planning/phases/19-classification-engine/19-RESEARCH.md (gatherCampaignEvidence Code Example, evaluation order, known-bad-indicator interpretation) - classifyCampaign returns an object with verdict ∈ {'SPAM','UNWANTED','THREAT'}, numeric confidence, string summary, string[] reasons, string[] recommendedActions, boolean requiresApproval, string id, string campaignId, string createdAt (CLASSIFY-01) - Simulation fixture (it-support.care OR breachsecurenow.com sender) with authResults fail but authResultsOriginal pass + delivered>0: verdict is SPAM or UNWANTED, NEVER THREAT (CLASSIFY-04 — allowlist short-circuits before THREAT tier) - THREAT only when blastRadius.status==='ok' AND (delivered>0 OR clicked>0) AND (hasHardAuthFail(effectiveAuthResults) on some message OR a known-bad indicator match) AND sender NOT on allowlist (D-03) - Known-bad indicator match = same attachment_hash or url value on ≥2 distinct messages in the campaign (cross-report correlation — no external reputation lookup) (D-03 interpretation, research A4) - When blastRadius.status!=='ok', or no message parsed, or no indicators: confidence < 1.0 and the specific source named in reasons (CLASSIFY-03) - Persisted reasons/summary contain no raw email body text; evidence arrays are capped (CLASSIFY-06) - Exactly one new classifications row INSERTed per call (append-only, no ON CONFLICT) — verified via the mocked query call args (D-02) Extend `campaign-classifier.test.ts` with a `describe('classifyCampaign', ...)` block using `vi.mock('./postgres-client')` (queryMock, matching campaign-grouping-service.test.ts) and `vi.mock('./mimecast-blast-radius', () => ({ getBlastRadius: (...) => getBlastRadiusMock(...) }))`. Route mock query responses by distinguishing SQL substring per call (`FROM campaigns`, `FROM reports`, `FROM messages`, `FROM indicators`, `INSERT INTO classifications`) — NOT by call order. Add the named tests: "returns exactly one verdict", "simulation allowlist" (both it-support.care and breachsecurenow.com fixtures, delivered>0, authResults fail → assert verdict !== 'THREAT'), "evidence bounding" (feed a campaign with many indicators; assert persisted reasons is a short bounded array and no reason string contains a raw body). Write these RED first.
Then implement in `campaign-classifier.ts`:
- `interface ClassifyResult { id: string; campaignId: string; verdict: 'SPAM'|'UNWANTED'|'THREAT'; confidence: number; summary: string; reasons: string[]; recommendedActions: string[]; requiresApproval: boolean; createdAt: string }` (export it).
- `gatherCampaignEvidence(campaignId)`: SELECT campaign row; SELECT reports `WHERE campaign_id=$1 ORDER BY created_at ASC` (LEFT JOIN contacts for requester_email); bulk-fetch messages `WHERE report_id = ANY($1::uuid[])` selecting `id, report_id, headers, message_id`; bulk-fetch indicators `WHERE message_id = ANY($1::uuid[])` selecting `id, message_id, indicator_type, value`. Parse `headers` JSONB into the NormalizedMessage-shaped fields needed (from.domain, returnPath, authResults, authResultsOriginal, subject). Use the EARLIEST report as canonical sender/subject/date-window for one `getBlastRadius()` call (±24h window around report.created_at); when no report exists, synthesize `{ status:'unavailable', reason:'not_configured' }` without calling Mimecast (research A6). Return a BOUNDED payload: counts + capped sample arrays (cap indicator/report samples at 10), never raw body text (CLASSIFY-06 / T-19-03 DoS guard).
- `classifyCampaign(campaignId)`: gather evidence → `if isKnownSimulationSender(any message) → skip THREAT, run evaluateSpamVsUnwanted only (D-06 short-circuit)` → else `evaluateThreatTier` (D-03) → else `evaluateSpamVsUnwanted` (D-04, SPAM if no suspicious signal, UNWANTED if a suspicious signal present but below THREAT bar) → `computeConfidence` → `mapVerdictToActions` → `computeRequiresApproval` → build a short `summary` string naming the verdict and top reason → INSERT one classifications row `RETURNING id::text AS id, created_at::text AS created_at` with `reasons`/`recommended_actions` as `JSON.stringify(...)::jsonb` → return ClassifyResult. Wrap the orchestrator body in try/catch that logs `[CAMPAIGN-CLASSIFIER]` + err.message (never full payloads — T-17-style) and rethrows.
- Known-bad indicator match computed over the gathered indicators: group by (indicator_type, value) for attachment_hash/url types, flag true if any value spans ≥2 distinct message_id.
npx vitest run lib/services/campaign-classifier.test.ts -t "returns exactly one verdict" npx vitest run lib/services/campaign-classifier.test.ts -t "simulation allowlist" npx vitest run lib/services/campaign-classifier.test.ts -t "evidence bounding" npx vitest run lib/services/campaign-classifier.test.ts npx tsc --noEmit --pretty - `npx vitest run lib/services/campaign-classifier.test.ts` passes all describe blocks including classifyCampaign - "simulation allowlist" test passes for BOTH it-support.care (From match) and breachsecurenow.com (Return-Path match, From.domain null) fixtures, each asserting `result.verdict !== 'THREAT'` despite `authResults` hard-fail and delivered>0 - "returns exactly one verdict" asserts result.verdict is one of the three literals and all six payload fields are present with correct types (CLASSIFY-01) - Mock asserts exactly one `INSERT INTO classifications` query and that it contains no `ON CONFLICT` (append-only, D-02) - `grep -n "authResultsOriginal" lib/services/campaign-classifier.ts` shows the precedence is applied inside the THREAT-tier path - `grep -n "getBlastRadius" lib/services/campaign-classifier.ts` shows exactly one call site guarded by "earliest report exists" - `npm test` full suite green; `npx tsc --noEmit --pretty` exits 0 classifyCampaign orchestrates evidence→rules→confidence→actions→append-only INSERT; simulation short-circuit and authResultsOriginal precedence proven by tests; full suite + tsc green.

<threat_model>

Trust Boundaries

Boundary Description
stored evidence → classifier Attacker-controlled email content (headers, sender domain) already persisted in messages/indicators crosses into verdict logic
classifier → classifications table Verdict/approval flag written; a wrong flag could later let Phase 20 gate a destructive action incorrectly

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-19-01 Spoofing domainMatchesAllowlist mitigate Match exact-domain-or-proper-subdomain only (d===allowed || d.endsWith('.'+allowed)); never .includes() substring — asserted by a test that it-support.care.attacker.net does NOT match
T-19-02 Tampering (logic drift) D-03/D-04/D-06 rule functions mitigate Pure, unit-tested TS functions + TS-constant allowlist (not a live-editable DB table); every rule covered by a dedicated test
T-19-03 Denial of Service gatherCampaignEvidence / persisted reasons mitigate Bounded evidence payload — counts + sample arrays capped at 10, never full report/message/indicator sets or raw body text embedded verbatim (doubles as CLASSIFY-06)
T-19-06 Spoofing (auth-verdict inversion) effectiveAuthResults mitigate Prefer authResultsOriginal over post-forward authResults so a forwarded genuine simulation isn't pushed to THREAT on an invalidated DKIM signature (Pitfall 1)
T-19-SC Tampering npm/pip/cargo installs accept No new packages installed this phase (classifier consumes only shipped project code + Node built-ins); Package Legitimacy Gate N/A per 19-RESEARCH.md
</threat_model>
- `npx vitest run lib/services/campaign-classifier.test.ts` — all describe blocks green - `npm test` — full suite green (no regression in campaign-grouping / mimecast / eml-parser) - `npx tsc --noEmit --pretty` — clean - CLASSIFY-01/02/03/04/06 each proven by a named test per 19-VALIDATION.md Per-Task Verification Map

<success_criteria>

  • classifyCampaign returns exactly one verdict with confidence + summary + reasons + recommendedActions + requiresApproval (CLASSIFY-01)
  • destructive action ⇒ requiresApproval:true; disable_forwarding_rule alone ⇒ false (CLASSIFY-02)
  • incomplete evidence lowers confidence and names the missing source (CLASSIFY-03)
  • it-support.care / breachsecurenow.com simulation fixtures never classified THREAT absent contrary evidence (CLASSIFY-04)
  • evidence is bounded/structured — no raw unbounded body reaches classifier/reasons (CLASSIFY-06) </success_criteria>
Create `.planning/phases/19-classification-engine/19-01-SUMMARY.md` when done