From e1c7eff08846c74f3ee687fba64ab59f278ae457 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 07:38:52 -0400 Subject: [PATCH] docs(19): research classification engine phase domain --- .../19-classification-engine/19-RESEARCH.md | 870 ++++++++++++++++++ 1 file changed, 870 insertions(+) create mode 100644 .planning/phases/19-classification-engine/19-RESEARCH.md diff --git a/.planning/phases/19-classification-engine/19-RESEARCH.md b/.planning/phases/19-classification-engine/19-RESEARCH.md new file mode 100644 index 0000000..9a2b953 --- /dev/null +++ b/.planning/phases/19-classification-engine/19-RESEARCH.md @@ -0,0 +1,870 @@ +# Phase 19: Classification Engine - Research + +**Researched:** 2026-07-16 +**Domain:** Deterministic rule-based security classification over structured phishing-triage evidence (Postgres-resident, no LLM) +**Confidence:** HIGH (architecture/schema/permissions — all read directly from shipped code) / MEDIUM (confidence-weight numbers, action-recommendation mapping — reasoned recommendations, not locked decisions) / MEDIUM (BSN/KnowBe4 domain findings — grounded in real ticket data, but domain lists are inherently incomplete/rotating) + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +**Architecture: Deterministic Rules Only** +- **D-01:** The classifier is a pure deterministic rule engine — no Anthropic/OpenRouter/LLM + calls in this phase. Same style as `lib/services/robotic-classifier.ts` (evidence-in → + rule-eval → verdict-out). This matches the ROADMAP goal and success criteria literally — + none of them describe an AI call. CLASSIFY-06's "before reaching any AI layer" language is + satisfied by shaping the evidence payload as bounded/structured now (see D-08), so a future + phase could bolt on an LLM-assist stage later without reworking the evidence shape — but this + phase does not build one. + +**Trigger: On-Demand Only** +- **D-02:** Classification runs strictly via `POST /api/phishing/campaigns/{id}/classify` — no + automatic classification on campaign creation/update. Each classify call inserts a new + `classifications` row (append-only history) — "current" verdict is the most recent row by + `created_at`. + +**Verdict Rules: THREAT Tier** +- **D-03:** THREAT requires BOTH (a) evidence the message actually reached someone — blast-radius + shows `delivered > 0` or `clicked > 0` via `getBlastRadius()` (Phase 17) — AND (b) a malicious + signal: a hard SPF/DKIM/DMARC **fail** (not `none`/unchecked), or a known-bad indicator + (attachment-hash/URL match from the `indicators` table). Either signal alone stays at + UNWANTED — contained blast radius means it isn't a realized threat yet. + +**Verdict Rules: SPAM vs. UNWANTED** +- **D-04:** SPAM = generic bulk/commercial signals — no spoofing, no malicious indicator matches, + sender not impersonating anyone. UNWANTED = has a suspicious signal but doesn't clear the + THREAT bar — the "legitimately suspicious but not spreading or spoofed" middle tier. + +**Confidence Scoring** +- **D-05:** Point-deduction from a full-evidence baseline of `1.0`. Subtract a fixed amount per + missing evidence source: no `.eml`/`messages` row parsed, no Mimecast data (`status: + 'unavailable'`), no attachment/URL indicators found. Each deduction is named explicitly in + `reasons`. Exact deduction weights are a planner/researcher call — not a user preference, as + long as the additive-from-1.0 shape and per-source naming are preserved. + +**KnowBe4 / Breach Secure Now Simulation Detection** +- **D-06:** Detection is a sender-domain allowlist, config-driven (not hardcoded inline), checked + against the parsed original message's `From`/`Return-Path` domain (Phase 16's + `NormalizedMessage`) — NOT against the report-pattern match alone. Phase 15's detector patterns + (`KNOWN_PHISHING_PATTERNS`) only prove the REPORT arrived via a KnowBe4-button-flavored flow; + they do not prove the underlying reported message is itself a simulation. If the allowlist + matches, force verdict to SPAM/UNWANTED and skip THREAT regardless of other signals (D-03/D-04 + still decide which of the two). +- **D-07 (added mid-discussion):** Research must search existing Autotask ticket history for real + BSN-originated report examples and fold a BSN sender-domain entry into this phase's allowlist if + enough signal exists. The allowlist structure (D-06) must support multiple vendors from day one. + +**Recommended Actions Vocabulary** +- **D-08:** Seven action types, split into two tiers: + - **Non-destructive:** `no_action` (SPAM), `warn_user` (UNWANTED), `disable_forwarding_rule` + (easily reversible — does NOT force approval on its own). + - **Destructive** (always `requires_approval: true`): `block_sender`, `purge_message`, + `reset_password`, `isolate_endpoint`. + This vocabulary becomes Phase 20's `remediation_actions.action_type` enum. The flag is OR'd + across all recommended actions, not per-action — recommending `disable_forwarding_rule` alone + must NOT force `requires_approval: true`, while recommending it alongside any destructive + action still does. + +### Claude's Discretion + +- Exact point-deduction weights for confidence scoring (D-05) — as long as the shape holds. +- Exact JSON shape of `reasons` / `recommended_actions` in the API response and `classifications` + row — follow camelCase API convention; both are JSONB columns in migration 097. +- Whether the KnowBe4/BSN sender-domain allowlist lives as a TypeScript constant (mirroring + `KNOWN_PHISHING_PATTERNS`) or a small config/DB table — must support multiple vendors and be + trivially extendable. +- Exact evaluation order/precedence when multiple rules could apply — the allowlist match (D-06) + short-circuits before D-03/D-04 tier evaluation; finer ordering is planner's call. +- How `isolate_endpoint`'s "configured provider path" question interacts with Phase 20 — Phase 19 + only needs to recommend it and mark it destructive. + +### Deferred Ideas (OUT OF SCOPE) + +- **BSN sender-domain confirmation** — if research finds no usable BSN pattern in existing ticket + history, ship this phase with a KnowBe4-only allowlist and treat adding BSN as a fast follow-up. + (RESOLVED BY THIS RESEARCH — see below: a real BSN pattern WAS found.) +- **`isolate_endpoint` provider wiring** — Phase 19 only recommends/flags destructive; Phase 20 + owns execution. +- **Non-BSN/KnowBe4 discussion scope creep** — none surfaced. + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| CLASSIFY-01 | Classify a campaign as exactly one of SPAM/UNWANTED/THREAT with confidence, summary, evidence-backed reasons, recommended actions, `requires_approval` | See "Verdict Decision Tree" and "Code Examples" — concrete rule-evaluation order and `classifications` row shape confirmed against migration 097 | +| CLASSIFY-02 | Any classification recommending a destructive action always sets `requires_approval: true` | See "Recommended Actions Mapping" — `DESTRUCTIVE_ACTIONS` set + OR-across-actions computation, with `disable_forwarding_rule`-alone counter-case explicitly covered | +| CLASSIFY-03 | Incomplete evidence lowers confidence and names the missing evidence in reasons | See "Confidence Scoring Weights" — concrete 0.4/0.3/0.2 deduction proposal with rationale, floors naturally at 0.1 | +| CLASSIFY-04 | Known KnowBe4 simulations not classified THREAT absent contrary evidence | See "D-07 Findings: BSN Ticket Evidence" — confirmed real `it-support.care` (KnowBe4) and `breachsecurenow.com` (BSN) sender-domain patterns from live Autotask ticket history | +| CLASSIFY-05 | Operator can (re-)trigger classification via `POST /api/phishing/campaigns/{id}/classify` | See "Auth/Route Pattern" — exact `requirePermission('phishing', 'analyze')` call-and-return shape confirmed from `analyze/route.ts` | +| CLASSIFY-06 | Classifier accepts structured, size-bounded evidence, not raw unbounded email | See "Evidence Assembly" — Phase 16 already truncates body previews to 500 chars; recommends capping indicator/report arrays in the persisted evidence too | + + +## Summary + +This phase adds one new pure-function module (`lib/services/campaign-classifier.ts`, following +the naming convention of `campaign-grouping-service.ts` and `mimecast-blast-radius.ts`) and one +new route (`POST /api/phishing/campaigns/{id}/classify`), following the exact +`requirePermission('phishing', 'analyze')` pattern already used by +`app/api/phishing/tickets/[ticket_id]/analyze/route.ts`. No new npm packages are required — the +classifier consumes types and functions already exported by Phases 16-18 +(`NormalizedMessage`/`AuthResults` from `eml-parser.ts`, `BlastRadiusResult` from +`mimecast-blast-radius.ts`, the `campaigns`/`reports`/`messages`/`indicators` schema from +migration 097) and writes one row per call to the already-existing `classifications` table. + +The highest-value finding from this research is empirical, not architectural: **live Autotask +ticket history confirms a real, historically consistent KnowBe4 phishing-simulation sending +domain (`it-support.care`, 219 tickets across 3+ years, ~37 distinct spoofed personas sharing one +domain) and a real Breach Secure Now sending domain (`breachsecurenow.com`, 11+ tickets)** — see +the dedicated D-07 section below. A second, equally load-bearing finding is a genuine pitfall +discovered in the same ticket data: **forwarded/reported simulation emails routinely show a +`fail` verdict on the primary `Authentication-Results` header while `Authentication-Results-Original` +(pre-forwarding) shows `pass`** — a classifier that checks only `authResults` (not +`authResultsOriginal`) risks misclassifying a genuine KnowBe4/BSN simulation as THREAT purely +because Outlook's "Report Message" forwarding step invalidates the original DKIM signature. Phase +16 anticipated exactly this in its own Open-Questions log ("Phase 19's classifier will care about +pre-remediation verdicts") — this research confirms the concern is real, using real ticket data, +not just theoretical. + +**Primary recommendation:** Build `lib/services/campaign-classifier.ts` as a single exported +`classifyCampaign(campaignId): Promise` function (pure evidence-gathering + +rule-evaluation, no class, mirroring `campaign-grouping-service.ts`), preferring +`authResultsOriginal` over `authResults` for the D-03 hard-fail check, using a TypeScript-constant +sender-domain allowlist (`KNOWN_SIMULATION_SENDERS`) seeded with `it-support.care` (KnowBe4) and +`breachsecurenow.com` (BSN), and computing "known-bad indicator match" as **an attachment-hash or +URL value shared across ≥2 distinct reports in the same campaign** (there is no external +threat-intel/reputation source anywhere in this codebase's dependency chain — this is the only +structurally available "match" signal). + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Evidence assembly (campaign → reports → messages → indicators + blast-radius call) | API/Backend (service layer) | Database | Pure data-gathering, no UI; lives in `lib/services/`, reads Postgres + calls Phase 17's abstraction | +| Verdict rule evaluation (D-03/D-04/D-06) | API/Backend (service layer) | — | Deterministic in-process logic, no I/O beyond the evidence already gathered | +| Confidence scoring (D-05) | API/Backend (service layer) | — | Pure arithmetic over the assembled evidence's completeness flags | +| `classifications` row persistence | Database | API/Backend | Single INSERT, append-only, no update/upsert | +| `POST /classify` trigger + auth | API/Backend (route handler) | — | `requirePermission('phishing','analyze')` early-return, matches Phase 18 convention | +| KnowBe4/BSN sender-domain allowlist | API/Backend (TS constant) | — | Mirrors `KNOWN_PHISHING_PATTERNS` precedent; no DB/UI needed this phase | + +No browser/client-tier or CDN/static-tier work exists in this phase — `UI hint: no` in ROADMAP.md +is consistent with the codebase reality (no `/admin/phishing` UI exists yet in any of Phases +15-18). + +## Standard Stack + +### Core + +No new packages. This phase is 100% composed of existing, already-installed dependencies: + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `pg` (via `postgresClient`) | 8.11.0 (existing) | Read campaign/report/message/indicator rows, INSERT `classifications` row | Already the project's only DB access path — no ORM | +| (none — mailparser/linkify-it already consumed upstream by Phase 16, not directly by this phase) | — | — | — | + +**Version verification:** N/A — no new packages requested. Verified via `package.json` (already +read as part of project context) that `pg@8.11.0` is the pinned version in use. + +### Alternatives Considered + +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| TS-constant sender-domain allowlist | `classification_rules`-style DB table (mirroring `robotic-classifier.ts`'s DB-driven rule cache) | DB table would let ops add a vendor domain without a deploy, matching the *existing* robotic-classifier precedent exactly — but that precedent is for ticket-triage rules edited by non-engineers via `/admin/workflow`, a UI that doesn't exist for phishing yet (`UI hint: no` this phase). A DB-backed allowlist with no admin UI is a bare table an operator would have to `psql` into directly — worse ergonomics than a reviewed TS constant, and higher risk (a compromised/mistaken raw SQL edit could silently suppress a real THREAT verdict for an attacker-controlled domain). Recommendation: TS constant now; revisit as a DB table only if/when an admin UI is scoped. | + +**Installation:** N/A — no `npm install` needed this phase. + +## Package Legitimacy Audit + +**N/A this phase.** No new external packages are installed — the classifier consumes only +already-shipped project code (`eml-parser.ts`, `mimecast-blast-radius.ts`, `postgres-client.ts`) +and Node/TypeScript built-ins. The Package Legitimacy Gate protocol was not run because there is +nothing to audit; if the planner introduces any new dependency during implementation, this gate +must be run at that time. + +## Architecture Patterns + +### System Architecture Diagram + +``` +POST /api/phishing/campaigns/{id}/classify + │ + ▼ +requirePermission('phishing','analyze') ──(fail)──► 403/401 response + │ (pass) + ▼ +classifyCampaign(campaignId) [lib/services/campaign-classifier.ts] + │ + ├─► gatherCampaignEvidence(campaignId) + │ │ + │ ├─► SELECT campaign row (campaigns) + │ ├─► SELECT linked reports (reports, ordered by created_at ASC) + │ ├─► SELECT linked messages (messages, keyed by report_id IN (...)) + │ ├─► SELECT linked indicators (indicators, keyed by message_id IN (...)) + │ └─► getBlastRadius({sender, recipient, subject, dateWindow}) [Phase 17, ephemeral] + │ + ▼ + EvidencePayload (bounded: counts + capped sample arrays, never raw body text beyond + Phase 16's existing 500-char bodyPreview cap) + │ + ▼ + evaluateSimulationAllowlist(evidence) ──(match)──► forced SPAM/UNWANTED path (D-06) + │ (no match) + ▼ + evaluateThreatTier(evidence) (D-03: delivered/clicked > 0 AND (hard auth-fail OR known-bad indicator match)) + │ (no) + ▼ + evaluateSpamVsUnwanted(evidence) (D-04) + │ + ▼ + computeConfidence(evidence) (D-05: 1.0 minus per-missing-source deductions, each named in reasons) + │ + ▼ + mapVerdictToActions(verdict, evidence) (D-08 vocabulary; requires_approval = OR of destructive flags) + │ + ▼ + INSERT INTO classifications (campaign_id, verdict, confidence, summary, reasons, recommended_actions, requires_approval) + │ + ▼ + NextResponse.json({ id, campaignId, verdict, confidence, summary, reasons, recommendedActions, requiresApproval }) +``` + +A reader can trace one classify call end-to-end: HTTP → permission check → evidence gather (4 +DB queries + 1 ephemeral Mimecast call) → 4 pure rule-evaluation stages in a fixed order → 1 +INSERT → JSON response. + +### Recommended Project Structure + +``` +lib/services/ +├── campaign-classifier.ts # NEW — classifyCampaign(), gatherCampaignEvidence(), pure rule fns +├── campaign-classifier.test.ts # NEW — vitest, mocks postgresClient + getBlastRadius +├── campaign-grouping-service.ts # existing (Phase 18) — read, not modified +├── mimecast-blast-radius.ts # existing (Phase 17) — read, not modified +├── eml-parser.ts # existing (Phase 16) — read, not modified +├── phishing-eml-service.ts # existing (Phase 16) — read, not modified +└── phishing-detector.ts # existing (Phase 15) — read, not modified (KNOWN_PHISHING_PATTERNS + # is the REPORT-pattern list; do not confuse with the NEW + # sender-domain allowlist this phase adds) + +app/api/phishing/campaigns/[id]/ +└── classify/ + └── route.ts # NEW — POST handler, requirePermission('phishing','analyze') +``` + +### Pattern 1: Pure evidence-in / rule-eval / verdict-out (D-01's architectural analog) + +**What:** `robotic-classifier.ts`'s `evaluateContains()`/`evaluateRule()` style — small, pure, +deterministic, no regex/eval, string-only matching. This phase's rule functions should follow the +same style but WITHOUT `robotic-classifier.ts`'s DB-driven rule-cache layer (`loadRules()`, +`rulesCache`) — there is no admin UI or `classification_rules`-equivalent table for phishing this +phase, so the D-03/D-04/D-06 rules are simple hardcoded TypeScript conditionals, not +DB-configurable rows. + +**When to use:** Every verdict-decision function in this phase. + +**Example (based on `robotic-classifier.ts` conventions, adapted):** +```typescript +// Source: lib/services/robotic-classifier.ts (existing pattern), adapted for Phase 19 +function isSimulationSender(fromDomain: string | null, returnPathDomain: string | null): boolean { + const domains = [fromDomain, returnPathDomain].filter((d): d is string => d !== null); + return domains.some((d) => + KNOWN_SIMULATION_SENDERS.some((entry) => + entry.domains.some((allowed) => d.toLowerCase() === allowed || d.toLowerCase().endsWith(`.${allowed}`)) + ) + ); +} +``` + +### Pattern 2: Shared-core service, one exported orchestrator function (D-01/D-02, mirrors Phase 18) + +**What:** `campaign-grouping-service.ts` and `phishing-detector.ts` both export ONE orchestrator +function (`groupReportIntoCampaign`, `detectPhishingTicket`) that the route handler calls +directly — no class, no singleton instance. `campaign-classifier.ts` should follow the identical +shape: `export async function classifyCampaign(campaignId: string): Promise`. + +**When to use:** The single entry point the new route calls. + +### Pattern 3: Auth/route early-return (Phase 18 D-06 convention, reused verbatim) + +**What:** Exact shape confirmed from `app/api/phishing/tickets/[ticket_id]/analyze/route.ts`: + +```typescript +// Source: app/api/phishing/tickets/[ticket_id]/analyze/route.ts (lines 18-23) +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requirePermission('phishing', 'analyze'); + if (error) return error; + + const { id } = await params; + // ... UUID validation (see campaigns/[id]/route.ts's UUID_RE guard), then classifyCampaign(id) +} +``` + +Note: the SAME `'analyze'` action is reused, not a new permission — confirmed in `lib/permissions.ts` +(`phishing: ["read", "analyze", "approve", "remediate"]`) and Phase 18's canonical_refs, which +explicitly calls out that `/classify` should use `'analyze'`, not introduce a new action. + +### Anti-Patterns to Avoid + +- **Do not gate the THREAT-tier auth check on `authResults` alone.** See "Common Pitfalls" below — + this is empirically confirmed to misfire on real BSN/KnowBe4-forwarded tickets in this org's own + history. +- **Do not treat `KNOWN_PHISHING_PATTERNS` (report-button pattern match) as proof of "this is a + simulation."** D-06 already forbids this; confirmed independently by ticket 698321 in this + research (a real/ambiguous phishing report that matched "Source: KnowBe4 Phish Alert Button" + purely because it was reported via that plugin — the underlying message was a suspicious + Mimecast-digest-styled email, not a KnowBe4 simulation). +- **Do not build a `classification_rules` DB table / admin UI for this phase.** No such + requirement exists in CLASSIFY-01..06, and `robotic-classifier.ts`'s DB-driven pattern is + explicitly NOT what D-01 asks for (D-01 cites `evaluateContains()`'s pure-function style, not + the DB-rule-cache wrapper around it). + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Delivery/click evidence | A new Mimecast query | `getBlastRadius()` (Phase 17) | Already handles config-gating, caching, graceful `unavailable` degrade, and the fan-out merge logic — re-implementing any part of this violates the Phase 17 canonical-read contract | +| SPF/DKIM/DMARC verdict parsing | A new Authentication-Results parser | `NormalizedMessage.authResults`/`authResultsOriginal` (Phase 16) | Already hand-rolled once, deliberately (to avoid `mailauth`'s live-DNS-verification side effect) — do not re-parse raw headers in this phase | +| Attachment/URL indicator extraction | A new indicator scan over `messages.headers`/`.urls`/`.attachments` JSONB | The already-persisted `indicators` table rows | Phase 16 already normalized these into typed rows (`attachment_hash`/`url`/`sender`) — read them, don't re-derive from raw JSONB | +| Report-pattern matching | A second KnowBe4/BSN detector | `phishing-detector.ts`'s `KNOWN_PHISHING_PATTERNS` (already applied upstream in Phase 15) | This phase's allowlist is a DIFFERENT signal (sender domain of the original message, not the report-button pattern) — do not duplicate or extend the existing pattern list for this purpose | +| Threat-intel/reputation lookup for "known-bad" indicators | A URL/hash reputation API call (VirusTotal, URLhaus, etc.) | Cross-report indicator correlation within the campaign (see "Known-Bad Indicator" pitfall below) | No such integration exists anywhere in this codebase (confirmed via grep across `lib/`, `CLAUDE.md`, `ARCHITECTURE.md` — zero matches) and none is listed as a phase dependency; introducing one would be scope creep and a new external service this phase was never scoped to configure | + +**Key insight:** Every evidence source this phase needs already exists as a typed, tested +abstraction from Phases 15-18. The entire job of `campaign-classifier.ts` is to READ those +abstractions and apply the D-03/D-04/D-05/D-06/D-08 rules — there is no new I/O to hand-roll. + +## Common Pitfalls + +### Pitfall 1: Forwarding-induced auth-verdict inversion (empirically confirmed, HIGH severity) + +**What goes wrong:** A genuine KnowBe4/BSN security-awareness email, reported via Outlook's +"Report Message" add-in (which forwards/wraps the original message), shows `spf=fail`, +`dkim=fail`, `dmarc=fail` on the PRIMARY `Authentication-Results` header — even though the +message legitimately passed all three at the point of original delivery. If the classifier's D-03 +hard-fail check reads only `NormalizedMessage.authResults` (not `authResultsOriginal`), it will +find a "hard fail" and can push a genuine simulation into THREAT-tier evaluation. + +**Why it happens:** Forwarding an email (or the internal M365 "report to Microsoft" pipeline +re-wrapping it) changes the envelope sender and can invalidate the original DKIM signature: the +receiving/reporting hop's own `Authentication-Results` header reflects THIS re-transmission, not +the original delivery. Microsoft (and Mimecast, when in the path) preserve the original, +pre-forwarding verdict in a separate `Authentication-Results-Original` header — which Phase 16 +already parses into `NormalizedMessage.authResultsOriginal` for exactly this reason (see Phase +16's own Open-Questions log: *"parse both if present... Phase 19's classifier will care about +pre-remediation verdicts for Mimecast-protected tenants"*). + +**Empirical evidence (this research, ticket #610787, `T20260716.0291`):** A real +`breachsecurenow.com`-originated "Welcome to Your Security Training Program" ticket shows: +``` +authentication-results: spf=fail ... dkim=fail (body hash did not verify) ...dmarc=fail action=quarantine ... +authentication-results-original: dkim=pass header.d=breachsecurenow.com ...; dmarc=pass (policy=quarantine) ...; spf=pass ... +``` +A second ticket (#640079) shows the SAME domain's SAME legitimate training-notification email +misclassified by an automated security-alert tool as a "High severity" phishing/spoofing threat, +purely because it only looked at the primary (post-forward) auth verdict. + +**How to avoid:** When evaluating D-03's "hard SPF/DKIM/DMARC fail" condition, check +`authResultsOriginal` FIRST if present on a given message; only fall back to `authResults` when +`authResultsOriginal` is null. Do this per-message (a campaign can have multiple linked messages). + +**Warning signs:** A THREAT verdict where the ONLY malicious signal is an auth failure, on a +message whose `authResultsOriginal` (if present) shows all-pass — this is very likely this exact +pitfall, not a real threat. + +### Pitfall 2: "Known-bad indicator match" has no defined data source (ambiguity, MEDIUM severity) + +**What goes wrong:** D-03 says THREAT requires "...OR a known-bad indicator (attachment-hash/URL +match from Phase 16's `indicators` table)." Taken literally, "match" implies matching against +some external known-bad reference (a reputation/threat-intel list) — but no such integration +exists anywhere in this codebase's dependency chain (confirmed: zero references to VirusTotal, +URLhaus, AbuseIPDB, or any hash/URL reputation service in `lib/`, `CLAUDE.md`, `ARCHITECTURE.md`, +or the v3.0 phase list). `MimecastThreatEvent` (in `mimecast-client.ts`) DOES carry a `verdict` +and `threatLevel` field internally, but `getBlastRadius()` (the only contract this phase is +allowed to read per canonical_refs) does not surface either field in `BlastRadiusResult` — it +only derives a `clicked` count from threat events, discarding the rest. + +**Why it happens:** The phrase "match" was written assuming a matching *mechanism* exists, but +none of Phases 15-18 built one — `indicators` rows are extracted-and-stored values, not +reputation-scored ones. + +**How to avoid:** Interpret "known-bad indicator match" as **the SAME attachment-hash or URL value +appearing on ≥2 distinct reports/messages within the same campaign** — i.e., independent +confirmation that multiple different recipients received the identical artifact, which is itself +a real signal of a deliberate, spreading campaign rather than one person's one-off spam. This is +structurally cheap to compute (`GROUP BY value HAVING COUNT(DISTINCT message_id) >= 2` over the +campaign's indicators) and requires no new external dependency. Flag this interpretation clearly +to the user/planner as a reasoned substitution for an ambiguous decision, not a locked fact. + +**Warning signs:** If the planner instead tries to wire in a live reputation API call, that is +scope creation beyond CLASSIFY-01..06 and beyond this milestone's dependency list — flag for +discussion before building it. + +### Pitfall 3: `From:` header may lack a visible email address on some real report tickets + +**What goes wrong:** Not every BSN/KnowBe4-forwarded message has a `` address on its +`From:` header when it reaches the ticket/evidence pipeline — ticket #610787's raw header dump +shows `From: "Wulf Consulting, Inc."` with NO address at all (display-name only). If +`NormalizedMessage.from.email`/`.domain` comes back null for a genuine simulation message, the D-06 +sender-domain allowlist check (which is specified against `From`/`Return-Path` domain) will +silently fail to match on the `From` side. + +**Why it happens:** Some display-name-customization or forwarding paths (this appears specific to +how BSN/M365 renders certain forwarded messages) produce a `From:` header with no bracketed +address. The `Return-Path`/envelope-sender (`smtp.mailfrom=`) is far more reliably present and, in +the same tickets, DOES carry the real sending domain (e.g., +`bounces11489798-daa2-wchapman=seubert.com@em8721.breachsecurenow.com`). + +**How to avoid:** D-06 already says to check BOTH `From` and `Return-Path` domain — this research +confirms that's not just belt-and-suspenders, it's load-bearing: implement the allowlist check as +"`from.domain` matches OR `returnPath`'s domain-part matches," never `from.domain` alone. + +**Warning signs:** A KnowBe4/BSN-domain allowlist that only ever matches on synthetic test fixtures +(which always have complete `From:` addresses) but silently fails on some fraction of real +campaigns. + +### Pitfall 4: KnowBe4/BSN sender-domain lists are inherently incomplete and rotate over time + +**What goes wrong:** KnowBe4's own documentation confirms organizations are issued a CSV of ~53 +"phish link domains" (system + custom), and this research's own ticket-history scan surfaced at +least 7 KnowBe4-flavored domains beyond `it-support.care` (`customer-portal.info`, +`member-services.info`, `cloud-service-care.com`, `logineverification.com`, `secureaccess.biz`, +`packagetrackingportal.com`, and others) that were NOT independently confirmed as KnowBe4-owned in +this research pass (only `it-support.care` was confirmed with high confidence via the +multi-persona/single-domain pattern — see D-07 section below). A hardcoded allowlist seeded from +today's ticket history will miss future/rotated domains. + +**How to avoid:** Ship the allowlist as an easily-extendable, clearly-commented TS constant +(structured per-vendor, e.g. `{ vendor: 'knowbe4', domains: [...] }`), and explicitly document in +the code comment that this list is NOT exhaustive and should be refreshed periodically from new +ticket evidence or a vendor-provided domain export — do not present it as a complete/permanent +solution. + +**Warning signs:** A new "Phishing Report" ticket from a not-yet-listed KnowBe4/BSN domain gets +classified as UNWANTED/THREAT instead of SPAM/UNWANTED-via-simulation — expected/acceptable +degradation, not a bug, given D-07's explicit scope ("ship KnowBe4-only if BSN can't be confirmed +... treat adding [more] as a fast follow-up"). + +## D-07 Findings: BSN Ticket Evidence (highest-priority research item) + +Queried `tickets` table directly (`docker exec pulse-postgres psql ...`) for +`title/description ILIKE '%breach secure now%' OR '%BSN%'` and cross-referenced against the +existing `KNOWN_PHISHING_PATTERNS` matches. Findings: + +1. **A real Breach Secure Now sender-domain pattern exists and is usable.** Ticket + `#610787` / `T20260716.0291` — title `Phishing:cd85e335-e22d-4dc8-c4df-08ddc49a6050|no-reply@breachsecurenow.com|(Welcome to Your Security Training Program) 7/16/2025 11:43:53 PM` + — is a genuine BSN training-program notification, reported via the same Microsoft "Report + Message"-flavored flow that produces `userSubmissionsReportMessage`/`reported message + destinations`/`Microsoft directly` matches in `KNOWN_PHISHING_PATTERNS`. Its raw headers show: + - `smtp.mailfrom=em8721.breachsecurenow.com` (envelope/bounce subdomain — SendGrid-style VERP) + - `header.d=breachsecurenow.com`, `header.from=breachsecurenow.com` (DKIM/DMARC domain) + - A sibling ticket, `#610770` (same day), and `#650284` ("User inquiry about potential phishing + email from breachsecurenow.com"), corroborate the same domain independently. + **Recommendation:** add `breachsecurenow.com` to the sender-domain allowlist (match on exact + domain OR any subdomain, since the envelope sender uses rotating `emNNNN.breachsecurenow.com` + subdomains — confirm via suffix match, not exact-string match). + +2. **A real, high-confidence KnowBe4 sender-domain pattern also exists** (not explicitly asked for + in D-07, but directly relevant to D-06/CLASSIFY-04, and far more load-bearing than the BSN + finding given `it-support.care` appears in **219 tickets** across **~37 distinct impersonated + personas** — Apple, Amazon, Adobe, Discord, Instagram, iTunes, Microsoft, Netflix, ChatGPT, + AT&T, Dropbox, "SSN Fraud Alert," "Google Security Alert" — all sharing the ONE sending domain + `it-support.care`, with local-parts like `noreply@`, `security@`, `notification@`, + `humanrec@`, `webinars@`). This one-domain/many-personas pattern is the hallmark of a + security-awareness-training template library, not a real attacker's infrastructure (a real + phishing campaign targeting one org rarely rotates through dozens of unrelated brand + impersonations from the same domain over 3+ years). Ticket `#624439` / + `Phishing:00000000-0000-0000-0000-000000000000|noreply@it-support.care|(Phishing Test Exempt)` + — the literal subject "Phishing Test Exempt" plus the all-zero GUID — is strong direct + confirmation this is KnowBe4's own "this was a known test, don't alert" marker baked into the + simulated email itself. + **Confidence:** MEDIUM-HIGH — grounded in real, voluminous ticket data and a highly distinctive + pattern, but NOT independently confirmed against KnowBe4's own published domain list (no such + public list was found; KnowBe4 support explicitly says the full ~53-domain CSV is + account-specific and only available by contacting them directly). Tag `[ASSUMED]` per the + package/claim-provenance rule — recommend the planner surface this to the user for a quick + sanity check ("does `it-support.care` ring a bell as your KnowBe4 simulation domain?") before + locking it in, even though the evidence is strong. + **Recommendation:** add `it-support.care` to the allowlist as the KnowBe4 entry. + +3. **Other candidate domains found in the same "Phishing Report -" ticket population were NOT + independently confirmed** and should NOT be added without further evidence: + `customer-portal.info` (118 tickets), `member-services.info` (106), `cloud-service-care.com` + (60), `logineverification.com` (35), `secureaccess.biz` (17), `packagetrackingportal.com` (16). + These share `it-support.care`'s generic-corporate-naming flavor and could plausibly be more + KnowBe4 system domains — but some high-volume domains in the same list + (`bankonlinesupport.com`, 50 tickets; `mlcrosoft.live`, 19, an obvious typosquat) are very + likely GENUINE phishing/spam, not simulations, so volume alone doesn't distinguish + vendor-owned from attacker-owned domains. Do not add these without a second, independent + confirmation signal (e.g., asking the user, or finding a ticket with the same + "Phishing Test Exempt"/all-zero-GUID marker seen for `it-support.care`). + +4. **Report-pattern match does NOT imply simulation** — directly confirmed, not just theoretical. + Ticket `#698321` ("Phishing Report - You have new held messages") matched + `KNOWN_PHISHING_PATTERNS`'s "Source: KnowBe4 Phish Alert Button" and "Phishing Report" patterns + (i.e., it WAS reported via the KnowBe4 PAB plugin) but its actual content is a suspicious + Mimecast bulk-digest-styled email with obfuscated redirect URLs — almost certainly a genuine + phishing/spam attempt that a user reported using the same button KnowBe4 simulations use to + auto-report themselves. This is exactly D-06's stated rationale, independently reproduced with + real data: the report-pattern match only proves how it was reported, never what it is. + +## Code Examples + +### Evidence assembly (report/message/indicator gathering, bounded) + +```typescript +// Source: pattern derived from app/api/phishing/campaigns/[id]/route.ts's existing +// bulk-fetch-by-id-array shape (reportIds -> messagesRes -> messageIds -> indicatorsRes) +async function gatherCampaignEvidence(campaignId: string) { + const campaign = await postgresClient.query(/* SELECT * FROM campaigns WHERE id = $1 */); + const reports = await postgresClient.query( + `SELECT r.*, 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 reportIds = reports.rows.map((r) => r.id); + const messages = reportIds.length + ? await postgresClient.query( + `SELECT * FROM messages WHERE report_id = ANY($1::uuid[])`, [reportIds] + ) + : { rows: [] }; + const messageIds = messages.rows.map((m) => m.id); + const indicators = messageIds.length + ? await postgresClient.query( + `SELECT * FROM indicators WHERE message_id = ANY($1::uuid[])`, [messageIds] + ) + : { rows: [] }; + + // Blast radius: use the EARLIEST report as the canonical evidence source for + // sender/subject/date-window (mirrors campaign-grouping-service.ts's own + // `ORDER BY r.created_at ASC` convention for "the original" report). + const primaryReport = reports.rows[0]; + const primaryMessage = messages.rows.find((m) => m.report_id === primaryReport?.id); + const senderIndicator = indicators.rows.find( + (i) => i.message_id === primaryMessage?.id && i.indicator_type === 'sender' + ); + const blastRadius = primaryReport + ? await getBlastRadius({ + sender: senderIndicator?.value ?? primaryMessage?.headers?.from?.email ?? '', + recipient: primaryReport.requester_email ?? '', + subject: primaryMessage?.headers?.subject ?? primaryReport.title ?? '', + dateWindow: { + start: new Date(new Date(primaryReport.created_at).getTime() - 24 * 60 * 60 * 1000), + end: new Date(new Date(primaryReport.created_at).getTime() + 24 * 60 * 60 * 1000), + }, + }) + : { status: 'unavailable' as const, reason: 'not_configured' as const }; + + return { campaign: campaign.rows[0], reports: reports.rows, messages: messages.rows, indicators: indicators.rows, blastRadius }; +} +``` + +### Auth-verdict precedence (Pitfall 1 fix) + +```typescript +// Prefer the pre-forwarding verdict when present (Pitfall 1) — do this per-message. +function effectiveAuthResults(message: { headers: { authResults: AuthResults; authResultsOriginal: AuthResults | null } }): AuthResults { + return message.headers.authResultsOriginal ?? message.headers.authResults; +} + +function hasHardAuthFail(effective: AuthResults): boolean { + return effective.spf === 'fail' || effective.dkim === 'fail' || effective.dmarc === 'fail'; +} +``` + +### KnowBe4/BSN sender-domain allowlist (D-06/D-07) + +```typescript +// lib/services/campaign-classifier.ts +// 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. +export const KNOWN_SIMULATION_SENDERS: readonly { vendor: string; domains: readonly string[] }[] = [ + { + vendor: 'knowbe4', + domains: ['it-support.care'], // 219 tickets, ~37 impersonated personas — see D-07 finding #2 + }, + { + vendor: 'breach-secure-now', + domains: ['breachsecurenow.com'], // confirmed via ticket #610787/#610770/#650284 — D-07 finding #1 + }, +]; + +function domainMatchesAllowlist(domain: string): boolean { + const lower = domain.toLowerCase(); + return KNOWN_SIMULATION_SENDERS.some((entry) => + entry.domains.some((allowed) => lower === allowed || lower.endsWith(`.${allowed}`)) + ); +} + +// Pitfall 3: check BOTH From domain and Return-Path domain — From may lack an address. +function isKnownSimulationSender(message: NormalizedMessage): boolean { + const fromDomain = message.from.domain; + const returnPathDomain = message.returnPath?.split('@')[1] ?? null; + return [fromDomain, returnPathDomain] + .filter((d): d is string => d !== null) + .some(domainMatchesAllowlist); +} +``` + +### Confidence scoring (D-05) + +```typescript +interface ConfidenceResult { confidence: number; reasons: string[] } + +function computeConfidence(evidence: { + hasAnyMessage: boolean; + blastRadiusStatus: 'ok' | 'unavailable'; + hasAttachmentOrUrlIndicators: boolean; +}): 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 }; +} +``` + +Weights chosen so that: message-parse absence (0.4) is weighted 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 floor of 0.1 confidence when all three evidence sources are +missing — no extra clamping logic needed, and confidence is never presented as exactly 0. + +### Recommended actions mapping (D-08) + +```typescript +const DESTRUCTIVE_ACTIONS = new Set(['block_sender', 'purge_message', 'reset_password', 'isolate_endpoint']); + +function mapVerdictToActions(verdict: 'SPAM' | 'UNWANTED' | 'THREAT', evidence: { clicked: number }): 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 — reasoned interpretation, + // not an explicit D-08 rule; flag for planner/user confirmation. + if (evidence.clicked > 0) { + actions.push('reset_password', 'isolate_endpoint', 'disable_forwarding_rule'); + } + return actions; + } + } +} + +function computeRequiresApproval(actions: string[]): boolean { + return actions.some((a) => DESTRUCTIVE_ACTIONS.has(a)); +} +``` + +### `classifications` INSERT (append-only, migration 097 exact columns) + +```typescript +// Source: migrations/097_phishing_triage_schema.sql lines 114-124 +await postgresClient.query( + `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] +); +``` + +## State of the Art + +Not directly applicable — this phase does not depend on any fast-moving external library or API +version. The relevant "state" is entirely this codebase's own Phase 15-18 output, all read +directly above. + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| N/A | N/A | N/A | N/A | + +**Deprecated/outdated:** None applicable this phase. + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | `it-support.care` is a KnowBe4-owned phishing-simulation sending domain | D-07 Findings #2, Code Examples (`KNOWN_SIMULATION_SENDERS`) | If wrong, genuine phishing sent from this domain (unlikely given the multi-persona pattern, but not impossible) would be forced to SPAM/UNWANTED and never reach THREAT — a false negative on a real attack. Mitigate: surface to user for a one-line confirmation before locking in; the "Phishing Test Exempt" subject-line ticket (#624439) is strong corroboration but not a KnowBe4-issued confirmation. | +| A2 | `breachsecurenow.com` is Breach Secure Now's legitimate training-notification sending domain | D-07 Findings #1 | Same class of risk as A1, but lower — envelope/DKIM/DMARC domain all agree across 3 independent tickets, and BSN is a named, known-to-the-user vendor (unlike a cold ticket-volume inference). | +| A3 | `customer-portal.info`, `member-services.info`, `cloud-service-care.com`, `logineverification.com`, `secureaccess.biz`, `packagetrackingportal.com` are additional KnowBe4 domains | D-07 Findings #3 | NOT recommended for inclusion in this research — listed only as candidates requiring further confirmation. Risk is null if the planner follows the recommendation to exclude them. | +| A4 | "Known-bad indicator match" (D-03) means cross-report indicator correlation within a campaign, not external reputation lookup | Common Pitfalls #2, Code Examples | If the user actually intended an external reputation check, this interpretation under-detects THREAT (misses attacker infrastructure known-bad via a real feed) — but building an unrequested external integration is a larger, riskier assumption. Flagged explicitly for planner/user sign-off. | +| A5 | TypeScript-constant allowlist (not DB table) is the right storage choice for D-06 | Standard Stack — Alternatives Considered | If wrong, ops will need a code deploy to add/remove a vendor domain rather than an admin UI edit — a process cost, not a correctness risk. Reversible later without a rework (per D-06's own multi-vendor-extensibility requirement). | +| A6 | Primary blast-radius call should use the campaign's EARLIEST report as canonical sender/subject/date-window | Code Examples (`gatherCampaignEvidence`) | If wrong (e.g. a later report has better/more-complete data), blast-radius lookup could target a slightly wrong subject/date-window and return fewer/no matches — degrades to `status:'unavailable'`-like under-evidence, not a false verdict, since D-03's gate still requires a real signal to fire. | + +## Open Questions + +1. **Should `disable_forwarding_rule` ever be recommended by this phase's rules, given no + evidence source in Phases 15-18 signals "a mailbox forwarding rule was created"?** + - What we know: D-08 defines the action as valid vocabulary and requires the + `requires_approval` invariant to hold even when it's the only recommended action — implying + at least one code path must be able to produce it (to exercise the test). + - What's unclear: no evidence source in this evidence model (blast radius, auth verdicts, + indicators) actually detects a mailbox rule. + - Recommendation: tie it to `clicked > 0` on a THREAT verdict (see Code Examples) as a + reasoned proxy for "possible account compromise, worth checking for a malicious forwarding + rule" — but confirm with the user/planner before locking in, since this is inference, not an + explicit CONTEXT.md decision. + +2. **Should the KnowBe4/BSN allowlist match check `Return-Path` via a parsed domain, or does + `NormalizedMessage` need a new `returnPathDomain` field?** + - What we know: `NormalizedMessage.returnPath` is currently a raw address STRING (e.g. + `bounces...@em8721.breachsecurenow.com`), not pre-split into a domain. + - What's unclear: whether the planner wants a small addition to `eml-parser.ts` (new + `returnPathDomain` derived field) or wants `campaign-classifier.ts` to do the + `.split('@')[1]` itself locally. + - Recommendation: do it locally in `campaign-classifier.ts` (as shown in Code Examples) — Phase + 16 is marked "read, does not modify" in this phase's canonical_refs, and the split is a + one-line, low-risk operation not worth reopening an already-shipped, tested module for. + +3. **Is a campaign's blast-radius lookup re-run on every `/classify` call, or cached/reused across + calls within the same campaign?** + - What we know: `getBlastRadius()` already has its own 5-minute Redis cache keyed on + messageId/composite sender+subject+date-window (Phase 17 D-?), and D-02 says every + `/classify` call inserts a new row (no caching of the VERDICT). + - What's unclear: nothing, actually — `getBlastRadius()`'s own cache already handles the + "don't hammer Mimecast on repeated classify calls" concern transparently. No new caching + logic needed in this phase. + - Recommendation: no action needed; call `getBlastRadius()` on every `classifyCampaign()` + invocation and let its existing cache absorb repeat calls. + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| Postgres 16 (`pulse-postgres` container) | All evidence reads + `classifications` INSERT | ✓ | 16 (running, confirmed via live query) | — | +| Mimecast (`MIMECAST_CLIENT_ID`/`SECRET`/`TOKEN_URL`) | `getBlastRadius()` D-03 delivery signal | ✓ (set in `.env`) | — | `getBlastRadius()` already degrades to `status:'unavailable'` if unset — no fallback needed in this phase's own code | +| Autotask (`AUTOTASK_*`) | Upstream only (reports/tickets already synced) — not called directly by this phase | ✓ (set in `.env`) | — | — | +| Backblaze B2 (`B2_*`) | Not used by this phase (raw `.eml` bytes are Phase 16's concern; classifier reads only parsed `messages`/`indicators` rows) | Not set in `.env` | — | N/A — this phase never touches B2 | + +**Missing dependencies with no fallback:** None. + +**Missing dependencies with fallback:** None relevant to this phase's own code — Mimecast +unavailability is an explicit D-05 evidence-completeness signal, not a blocker. + +## Validation Architecture + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | vitest 4.1.5 | +| Config file | `vitest.config.ts` (`include: ['lib/**/*.test.ts']`, node environment) | +| Quick run command | `npx vitest run lib/services/campaign-classifier.test.ts` | +| Full suite command | `npm test` | + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| CLASSIFY-01 | `classifyCampaign()` returns exactly one of SPAM/UNWANTED/THREAT + confidence + summary + reasons + recommendedActions + requiresApproval | unit | `npx vitest run lib/services/campaign-classifier.test.ts -t "returns exactly one verdict"` | ❌ Wave 0 | +| CLASSIFY-02 | Destructive action always forces `requires_approval: true`; `disable_forwarding_rule` alone does NOT | unit | `npx vitest run lib/services/campaign-classifier.test.ts -t "requires_approval invariant"` | ❌ Wave 0 | +| CLASSIFY-03 | Incomplete evidence lowers confidence and names the specific missing evidence | unit | `npx vitest run lib/services/campaign-classifier.test.ts -t "confidence deduction"` | ❌ Wave 0 | +| CLASSIFY-04 | Synthetic KnowBe4 (`it-support.care`) and BSN (`breachsecurenow.com`) simulation fixtures are not classified THREAT absent contrary evidence | unit | `npx vitest run lib/services/campaign-classifier.test.ts -t "simulation allowlist"` | ❌ Wave 0 | +| CLASSIFY-05 | `POST /classify` enforces `requirePermission('phishing','analyze')` and returns the classification | manual/smoke (matches existing convention — no route-level test files exist anywhere under `app/api/phishing/`) | `curl -X POST localhost:3100/api/phishing/campaigns/{id}/classify` (authenticated session) | N/A — route tests are not this codebase's convention | +| CLASSIFY-06 | Evidence payload is bounded/structured — no raw unbounded body text reaches the classifier or persisted `reasons` | unit | `npx vitest run lib/services/campaign-classifier.test.ts -t "evidence bounding"` | ❌ Wave 0 | + +### Sampling Rate + +- **Per task commit:** `npx vitest run lib/services/campaign-classifier.test.ts` +- **Per wave merge:** `npm test` +- **Phase gate:** Full suite green before `/gsd:verify-work` + +### Wave 0 Gaps + +- [ ] `lib/services/campaign-classifier.test.ts` — new file, covers CLASSIFY-01/02/03/04/06; + mock `postgresClient.query` (vi.mock, matching `campaign-grouping-service.test.ts`'s + existing convention) and mock `getBlastRadius` (vi.mock('./mimecast-blast-radius')) +- [ ] Fixture data for a synthetic KnowBe4 (`it-support.care`) message and a synthetic BSN + (`breachsecurenow.com`) message, each WITH an `authResultsOriginal` block showing pass + despite `authResults` showing fail (Pitfall 1 regression coverage) — no real customer data, + synthetic only, matching Phase 16's own fixture convention (`eml-parser.fixtures.ts`) +- [ ] Framework install: none — vitest already configured project-wide + +## Security Domain + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | No | Route relies on existing Better Auth session — no new auth surface this phase | +| V3 Session Management | No | Unchanged from existing middleware/session handling | +| V4 Access Control | Yes | `requirePermission('phishing', 'analyze')` — same action as the existing `/analyze` route, enforced server-side (not just middleware's cookie-presence check) | +| V5 Input Validation | Yes | Campaign `id` path param must be validated as a UUID before querying (mirror `campaigns/[id]/route.ts`'s `UUID_RE` guard) — an unvalidated malformed UUID would otherwise surface as an unhandled Postgres error → uncaught 500 | +| V6 Cryptography | No | No new crypto in this phase — auth-verdict parsing (SPF/DKIM/DMARC) is READ-ONLY interpretation of headers already verified by upstream mail infrastructure, never independent crypto verification (Phase 16 deliberately avoids `mailauth`'s live verification for exactly this reason) | + +### Known Threat Patterns for this stack + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| Classifier evidence payload growing unbounded (many reports/messages/indicators in one large campaign) | Denial of Service | Cap sample arrays in the persisted `reasons`/evidence JSONB (e.g. summarize counts + up to N sample values) rather than embedding every report/message/indicator verbatim — CLASSIFY-06's "size-bounded evidence" requirement doubles as a DoS guard | +| Simulation-allowlist bypass via spoofed sender domain lookalike (e.g. `it-support.care.evil.com`) | Spoofing | Match using exact-domain-or-proper-subdomain (`d === allowed \|\| d.endsWith('.' + allowed)`), never a bare `.includes(allowed)` substring check — a substring check would let `evil-it-support.care.attacker.net` or `it-support.care.attacker.net` falsely match | +| Malformed/malicious campaign `id` path param | Tampering | UUID-shape validation before querying (see V5 above), matching the existing `campaigns/[id]/route.ts` precedent exactly | +| Confidence-score/verdict logic drift silently weakening THREAT detection over time | Tampering (of business logic, not data) | Keep all D-03/D-04/D-06 rule functions pure, unit-tested, and reviewed via PR (TS constant, not a live-editable DB table) — per this research's Standard-Stack recommendation | + +## Sources + +### Primary (HIGH confidence — read directly from this session) + +- `/opt/stacks/pulse/lib/services/robotic-classifier.ts` — full file read, D-01 architectural analog confirmed +- `/opt/stacks/pulse/lib/services/mimecast-blast-radius.ts` — full file read, `BlastRadiusResult`/`BlastRadiusInput` exact shape +- `/opt/stacks/pulse/lib/services/eml-parser.ts` — full file read, `NormalizedMessage`/`AuthResults` exact shape +- `/opt/stacks/pulse/lib/services/phishing-detector.ts` — full file read, `KNOWN_PHISHING_PATTERNS`/`matchesPhishingPatterns` exact shape +- `/opt/stacks/pulse/lib/services/phishing-eml-service.ts` — full file read, `messages`/`indicators` INSERT shape confirmed +- `/opt/stacks/pulse/lib/services/campaign-grouping-service.ts` — full file read, campaign/report join conventions confirmed +- `/opt/stacks/pulse/lib/services/mimecast-client.ts` (lines 1-120) — `MimecastThreatEvent` shape confirmed, `verdict`/`threatLevel` fields NOT surfaced by `getBlastRadius()` +- `/opt/stacks/pulse/migrations/097_phishing_triage_schema.sql` — full file read, exact `classifications` columns/types +- `/opt/stacks/pulse/migrations/099_indicators_metadata.sql` — `indicators.metadata` column confirmed +- `/opt/stacks/pulse/lib/permissions.ts` — full file read, `phishing` resource/action vocabulary confirmed +- `/opt/stacks/pulse/app/api/phishing/tickets/[ticket_id]/analyze/route.ts` — full file read, exact auth-early-return pattern +- `/opt/stacks/pulse/app/api/phishing/campaigns/[id]/route.ts` and `.../campaigns/route.ts` — full files read, exact response-shape/UUID-validation/bulk-fetch conventions +- Live query against `pulse-postgres` (docker exec psql) — `tickets` table, 219 `it-support.care` tickets, 11 `breachsecurenow.com` tickets, confirmed raw MIME headers for tickets #610787, #640079, #624439, #695851, #698321 (D-07's core evidence) +- `.planning/phases/16-eml-mime-evidence-parser/16-RESEARCH.md` and `16-01-PLAN.md` — confirmed Phase 16's own anticipation of the auth-verdict-precedence concern for Phase 19 +- `.planning/phases/17-mimecast-blast-radius-lookup/17-VERIFICATION.md` — confirmed Phase 17's shipped behavior and D-05 multi-tenant gap +- `.planning/ROADMAP.md` (Phase 19 section, lines 427-438) — exact success-criteria wording + +### Secondary (MEDIUM confidence) + +- WebSearch: KnowBe4 "Manage Phish Link Domains" knowledge-base article — confirms KnowBe4 issues + a per-account CSV of ~53 system+custom phish-link domains, corroborating that `it-support.care` + is plausibly one of many such domains, though the specific domain was not found in any public + KnowBe4 document (searched, not found — see Assumptions Log A1) + +### Tertiary (LOW confidence) + +- The 6 additional candidate domains in D-07 Finding #3 (`customer-portal.info`, + `member-services.info`, `cloud-service-care.com`, `logineverification.com`, + `secureaccess.biz`, `packagetrackingportal.com`) — ticket-volume pattern only, explicitly NOT + recommended for inclusion without further confirmation + +## Metadata + +**Confidence breakdown:** +- Standard stack / architecture / schema: HIGH — all read directly from shipped, tested code in this repository +- D-07 BSN/KnowBe4 domain findings: MEDIUM-HIGH for `it-support.care` and `breachsecurenow.com` (strong ticket-data pattern, not vendor-confirmed); LOW for the 6 additional candidate domains (excluded from recommendation) +- Confidence-weight numbers (D-05) and action-recommendation mapping (D-08 triggers): MEDIUM — reasoned proposals grounded in the evidence model's actual shape, not locked user decisions; flagged in Open Questions/Assumptions Log for planner/user confirmation + +**Research date:** 2026-07-16 +**Valid until:** 30 days (stable internal codebase; the KnowBe4/BSN domain findings should be +re-validated against fresh ticket history if this phase's implementation slips more than ~60 days, +since simulated-phishing template domains do rotate)