23 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 19-classification-engine | 01 | tdd | 1 |
|
true |
|
|
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.mdFrom 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> |
<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>