docs(19): create phase plan

This commit is contained in:
lorentz 2026-07-16 07:49:00 -04:00
parent 8d4618cf0f
commit ef55e6dfd3
3 changed files with 435 additions and 3 deletions

View file

@ -358,7 +358,7 @@ summarizes classification, blast radius, and recommended/approved remediation st
- [x] **Phase 15: Data Model, Detection & Ticket Evidence** — New phishing schema (migration 097) + idempotent Autotask ticket scanner + base ticket evidence capture (completed 2026-07-15)
- [x] **Phase 16: EML/MIME Evidence Parser** — Pure RFC822/MIME parser: `.eml` selection (`rfc.eml` over `OriginatingEmail.eml`), normalized headers/URLs/attachments, sanitized body preview, synthetic-fixture tests (completed 2026-07-15)
- [x] **Phase 17: Mimecast Blast Radius Lookup** — Blast-radius abstraction with graceful `unavailable` degradation when Mimecast isn't configured (completed 2026-07-15)
- [ ] **Phase 18: Campaign Grouping & Phishing Analysis API** — Message-ID-first dedupe/grouping, on-demand single-ticket analysis, and the first `/api/phishing/*` routes with the ACCESS-01 auth convention (blocking gap CR-03 found via live verification 2026-07-16 — duplicate campaign on single-report re-analyze — see 18-VERIFICATION.md)
- [x] **Phase 18: Campaign Grouping & Phishing Analysis API** — Message-ID-first dedupe/grouping, on-demand single-ticket analysis, and the first `/api/phishing/*` routes with the ACCESS-01 auth convention (blocking gap CR-03 found via live verification 2026-07-16 — duplicate campaign on single-report re-analyze — see 18-VERIFICATION.md) (completed 2026-07-16)
- [ ] **Phase 19: Classification Engine** — Deterministic SPAM/UNWANTED/THREAT rule classifier over bounded structured evidence, KnowBe4-simulation guard, (re-)trigger API
- [ ] **Phase 20: Remediation, Approval & Audit Safety** — Proposed-only remediation actions, approve/remediate/mark-false-positive APIs, idempotent re-run, full audit trail
- [ ] **Phase 21: Autotask Triage Note** — Sanitized internal triage note posted via existing safe note-write path, or returned via API if no such path exists
@ -434,7 +434,9 @@ summarizes classification, blast radius, and recommended/approved remediation st
3. Classifying a campaign with incomplete evidence (no Mimecast data, no `.eml`) lowers confidence and names the specific missing evidence in the reasons
4. A synthetic KnowBe4 security-awareness-simulation fixture is not classified as `THREAT` absent contrary evidence
5. `POST /api/phishing/campaigns/{id}/classify` (re-)triggers classification, enforces the Phase 18 auth convention, and the classifier only ever receives structured, size-bounded evidence — long bodies are redacted/truncated before reaching any AI layer, and IT Glue-sourced evidence (if referenced) goes through the existing redacted `lib/services/analyzer/itglue-search.ts` path
**Plans**: TBD
**Plans**: 2 plans (2 waves)
- [ ] 19-01-PLAN.md — campaign-classifier.ts deterministic rule engine (evidence gather + D-03/D-04/D-06 rules + D-05 confidence + D-08 actions + append-only INSERT) + vitest suite + synthetic KnowBe4/BSN fixtures (CLASSIFY-01, CLASSIFY-02, CLASSIFY-03, CLASSIFY-04, CLASSIFY-06)
- [ ] 19-02-PLAN.md — POST /api/phishing/campaigns/[id]/classify route (requirePermission analyze + UUID guard + classifyCampaign delegation) (CLASSIFY-05)
**UI hint**: no
### Phase 20: Remediation, Approval & Audit Safety
@ -487,7 +489,7 @@ Phases execute in numeric order. v1.0 (Phases 1-9.1) shipped 2026-07-10. v2.0 (P
| 15. Data Model, Detection & Ticket Evidence | v3.0 | 3/3 | Complete | 2026-07-15 |
| 16. EML/MIME Evidence Parser | v3.0 | 3/3 | Complete | 2026-07-15 |
| 17. Mimecast Blast Radius Lookup | v3.0 | 1/1 | Complete | 2026-07-15 |
| 18. Campaign Grouping & Phishing Analysis API | v3.0 | 4/4 | Complete | 2026-07-16 |
| 18. Campaign Grouping & Phishing Analysis API | v3.0 | 5/5 | Complete | 2026-07-16 |
| 19. Classification Engine | v3.0 | 0/TBD | Not started | - |
| 20. Remediation, Approval & Audit Safety | v3.0 | 0/TBD | Not started | - |
| 21. Autotask Triage Note | v3.0 | 0/TBD | Not started | - |

View file

@ -0,0 +1,274 @@
---
phase: 19-classification-engine
plan: 01
type: tdd
wave: 1
depends_on: []
files_modified:
- lib/services/campaign-classifier.ts
- lib/services/campaign-classifier.test.ts
- lib/services/campaign-classifier.fixtures.ts
autonomous: true
requirements: [CLASSIFY-01, CLASSIFY-02, CLASSIFY-03, CLASSIFY-04, CLASSIFY-06]
must_haves:
truths:
- "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"
artifacts:
- path: "lib/services/campaign-classifier.ts"
provides: "classifyCampaign orchestrator, gatherCampaignEvidence, pure rule functions, KNOWN_SIMULATION_SENDERS constant, ClassifyResult type"
exports: ["classifyCampaign", "KNOWN_SIMULATION_SENDERS", "ClassifyResult"]
min_lines: 200
- path: "lib/services/campaign-classifier.test.ts"
provides: "Unit + mocked-integration coverage for CLASSIFY-01/02/03/04/06"
contains: "requires_approval invariant"
- path: "lib/services/campaign-classifier.fixtures.ts"
provides: "Synthetic KnowBe4 (it-support.care) and BSN (breachsecurenow.com) NormalizedMessage fixtures with authResultsOriginal=pass / authResults=fail"
contains: "it-support.care"
key_links:
- from: "lib/services/campaign-classifier.ts"
to: "getBlastRadius"
via: "import from ./mimecast-blast-radius"
pattern: "getBlastRadius"
- from: "lib/services/campaign-classifier.ts"
to: "classifications table"
via: "postgresClient INSERT (append-only, no ON CONFLICT)"
pattern: "INSERT INTO classifications"
- from: "lib/services/campaign-classifier.ts"
to: "messages.headers.authResultsOriginal"
via: "effectiveAuthResults precedence"
pattern: "authResultsOriginal"
---
<objective>
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`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- Contracts consumed by this plan. Use directly — no codebase exploration needed. -->
From lib/services/eml-parser.ts:
```typescript
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:
```typescript
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):
```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:
```sql
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)
```
</interfaces>
</context>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: Failing tests + fixtures + pure rule functions (RED→GREEN)</name>
<files>lib/services/campaign-classifier.test.ts, lib/services/campaign-classifier.fixtures.ts, lib/services/campaign-classifier.ts</files>
<read_first>
- 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)
</read_first>
<behavior>
- 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'
</behavior>
<action>
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.
</action>
<verify>
<automated>npx vitest run lib/services/campaign-classifier.test.ts -t "requires_approval invariant"</automated>
<automated>npx vitest run lib/services/campaign-classifier.test.ts -t "confidence deduction"</automated>
<automated>npx tsc --noEmit --pretty</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>All pure rule functions implemented and unit-tested green; fixtures exist with the authResultsOriginal-pass/authResults-fail inversion; tsc clean.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: classifyCampaign orchestrator + evidence gathering + append-only INSERT (RED→GREEN)</name>
<files>lib/services/campaign-classifier.ts, lib/services/campaign-classifier.test.ts</files>
<read_first>
- 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)
</read_first>
<behavior>
- 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)
</behavior>
<action>
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.
</action>
<verify>
<automated>npx vitest run lib/services/campaign-classifier.test.ts -t "returns exactly one verdict"</automated>
<automated>npx vitest run lib/services/campaign-classifier.test.ts -t "simulation allowlist"</automated>
<automated>npx vitest run lib/services/campaign-classifier.test.ts -t "evidence bounding"</automated>
<automated>npx vitest run lib/services/campaign-classifier.test.ts</automated>
<automated>npx tsc --noEmit --pretty</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>classifyCampaign orchestrates evidence→rules→confidence→actions→append-only INSERT; simulation short-circuit and authResultsOriginal precedence proven by tests; full suite + tsc green.</done>
</task>
</tasks>
<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>
<verification>
- `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
</verification>
<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>
<output>
Create `.planning/phases/19-classification-engine/19-01-SUMMARY.md` when done
</output>

View file

@ -0,0 +1,156 @@
---
phase: 19-classification-engine
plan: 02
type: execute
wave: 2
depends_on: ["19-01"]
files_modified:
- app/api/phishing/campaigns/[id]/classify/route.ts
autonomous: true
requirements: [CLASSIFY-05]
must_haves:
truths:
- "POST /api/phishing/campaigns/{id}/classify calls requirePermission('phishing','analyze') and rejects unauthenticated (401) / unauthorized (403) requests"
- "A malformed (non-UUID) campaign id returns 400 before any DB query"
- "A valid authorized request runs classifyCampaign and returns the verdict payload (id, campaignId, verdict, confidence, summary, reasons, recommendedActions, requiresApproval)"
- "An unknown campaign id returns 404"
artifacts:
- path: "app/api/phishing/campaigns/[id]/classify/route.ts"
provides: "POST classify route handler"
exports: ["POST"]
min_lines: 30
key_links:
- from: "app/api/phishing/campaigns/[id]/classify/route.ts"
to: "requirePermission('phishing','analyze')"
via: "auth-utils early-return"
pattern: "requirePermission\\('phishing', 'analyze'\\)"
- from: "app/api/phishing/campaigns/[id]/classify/route.ts"
to: "classifyCampaign"
via: "import from @/lib/services/campaign-classifier"
pattern: "classifyCampaign"
---
<objective>
Add the `POST /api/phishing/campaigns/{id}/classify` route — the on-demand (re-)trigger for
campaign classification (D-02). It enforces the Phase 18 auth convention (`requirePermission('phishing','analyze')`,
the SAME action as `/analyze`, not a new permission), validates the campaign id as a UUID (V5),
delegates to `classifyCampaign(id)` from Plan 01, and returns the flat camelCase verdict payload.
Purpose: An operator can (re-)classify a campaign through a properly access-controlled endpoint.
Output: `app/api/phishing/campaigns/[id]/classify/route.ts`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19-classification-engine/19-CONTEXT.md
@.planning/phases/19-classification-engine/19-PATTERNS.md
@.planning/phases/19-classification-engine/19-VALIDATION.md
<interfaces>
<!-- Contract from Plan 01 (wave 1). Import directly. -->
From lib/services/campaign-classifier.ts:
```typescript
export interface ClassifyResult {
id: string; campaignId: string;
verdict: 'SPAM' | 'UNWANTED' | 'THREAT';
confidence: number; summary: string;
reasons: string[]; recommendedActions: string[];
requiresApproval: boolean; createdAt: string;
}
export function classifyCampaign(campaignId: string): Promise<ClassifyResult>;
```
Auth (lib/permissions.ts): `phishing: ["read","analyze","approve","remediate"]`; `analyze`
is granted to super-admin + admin only (a plain `user` role has `read` only → expect 403).
UUID guard (from app/api/phishing/campaigns/[id]/route.ts):
```typescript
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: POST /api/phishing/campaigns/[id]/classify route handler</name>
<files>app/api/phishing/campaigns/[id]/classify/route.ts</files>
<read_first>
- app/api/phishing/tickets/[ticket_id]/analyze/route.ts (exact auth early-return: `const { error } = await requirePermission('phishing','analyze'); if (error) return error;`; try/catch → 500 with message; [PHISHING-ANALYZE] log-prefix convention)
- app/api/phishing/campaigns/[id]/route.ts (UUID_RE guard + 400; params is Promise<{ id: string }>; flat camelCase response shape; [PHISHING-CAMPAIGN-DETAIL] prefix; 404 when campaign not found)
- lib/permissions.ts (confirm phishing 'analyze' action already exists — do NOT add a new action)
- lib/services/campaign-classifier.ts (classifyCampaign signature + ClassifyResult from Plan 01)
- .planning/phases/19-classification-engine/19-PATTERNS.md (route/controller section — verbatim analog mapping)
</read_first>
<action>
Create `app/api/phishing/campaigns/[id]/classify/route.ts` exporting `async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> })`.
Body, in order:
1. `const { error } = await requirePermission('phishing', 'analyze'); if (error) return error;` — same `'analyze'` action as the existing `/analyze` route (Phase 18 D-06; T-19-04 access-control). Import `requirePermission` from `@/lib/auth-utils`.
2. `const { id } = await params;` then the `UUID_RE` guard copied verbatim from `campaigns/[id]/route.ts` — return `NextResponse.json({ error: 'Invalid campaign id' }, { status: 400 })` on failure, BEFORE any DB access (V5 / T-19-05 tampering guard). Use `campaigns/[id]/route.ts`'s UUID regex, NOT `analyze/route.ts`'s numeric `Number.isFinite` check (this id is a campaign UUID, not a ticket_id).
3. `try { ... } catch (err) { console.error('[PHISHING-CLASSIFY] Failed to classify campaign', id, err); return NextResponse.json({ error: 'Failed to classify campaign', message: err instanceof Error ? err.message : 'Unknown error' }, { status: 500 }); }`.
4. Inside try: verify the campaign exists first — `SELECT id FROM campaigns WHERE id = $1` (postgresClient default import from `@/lib/services/postgres-client`); if no row, return `NextResponse.json({ error: 'Campaign not found' }, { status: 404 })` (mirrors campaigns/[id]/route.ts 404). Then `const result = await classifyCampaign(id);` (import from `@/lib/services/campaign-classifier`) and `return NextResponse.json(result);``ClassifyResult` is already flat camelCase, no re-mapping needed.
Use path-alias `@/lib/...` imports (route-handler convention), NOT the sibling-filename imports used inside lib/services. Do not add the route to middleware public paths — `/api/phishing/*` is authenticated (this is intentional, T-19-04).
</action>
<verify>
<automated>npx tsc --noEmit --pretty</automated>
<automated>test -f "app/api/phishing/campaigns/[id]/classify/route.ts" &amp;&amp; grep -q "requirePermission('phishing', 'analyze')" "app/api/phishing/campaigns/[id]/classify/route.ts" &amp;&amp; echo AUTH_OK</automated>
<human-check>
Against a dev server (npm run dev, port 3100) with a known campaign UUID:
1. `curl -X POST http://localhost:3100/api/phishing/campaigns/{uuid}/classify` with a valid admin/super-admin session cookie → 200 with verdict/confidence/summary/reasons/recommendedActions/requiresApproval
2. Same with NO session cookie → 401
3. Same with a plain `user`-role session cookie → 403 (analyze not granted to user)
4. `curl -X POST http://localhost:3100/api/phishing/campaigns/not-a-uuid/classify` (authed) → 400
5. Valid UUID that is not a real campaign → 404
</human-check>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit --pretty` exits 0
- `grep -c "requirePermission('phishing', 'analyze')" "app/api/phishing/campaigns/[id]/classify/route.ts"` === 1 (T-19-04)
- `grep -q "UUID_RE" "app/api/phishing/campaigns/[id]/classify/route.ts"` — UUID guard present before DB access (T-19-05)
- `grep -q "classifyCampaign" "app/api/phishing/campaigns/[id]/classify/route.ts"` — delegates to the Plan 01 service
- Manual curl (human-check): 200 for authed admin, 401 no-session, 403 user-role, 400 malformed id, 404 unknown campaign
</acceptance_criteria>
<done>Route compiles, enforces analyze permission + UUID validation, delegates to classifyCampaign, and returns the verdict payload; manual auth curl matrix passes.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → API route | Unauthenticated/unauthorized HTTP request + attacker-controlled `id` path param crosses into the handler |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-19-04 | Elevation of Privilege | POST /classify handler | mitigate | `requirePermission('phishing','analyze')` early-return before any work; `analyze` granted only to admin/super-admin (V4 access control, server-side not just middleware cookie check) |
| T-19-05 | Tampering | `id` path param | mitigate | `UUID_RE` shape validation returns 400 before any query, preventing a malformed id from surfacing as an uncaught Postgres 500 (V5 input validation) |
| T-19-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed this phase; Package Legitimacy Gate N/A per 19-RESEARCH.md |
</threat_model>
<verification>
- `npx tsc --noEmit --pretty` — clean (route-handler tests are not this repo's convention; vitest.config does not scan app/**)
- Manual curl auth matrix (401 / 403 / 400 / 404 / 200) per 19-VALIDATION.md Manual-Only Verifications
- Route follows the ACCESS-01 carry-forward convention established in Phase 18
</verification>
<success_criteria>
- POST /api/phishing/campaigns/{id}/classify enforces requirePermission('phishing','analyze'), validates UUID, delegates to classifyCampaign, returns the verdict payload; rejects unauthenticated/unauthorized/malformed requests (CLASSIFY-05)
</success_criteria>
<output>
Create `.planning/phases/19-classification-engine/19-02-SUMMARY.md` when done
</output>