16 KiB
Phishing Triage Automation — LLM Context Document
How to use this document: Paste this whole file into a new chat session as context when you want to brainstorm, design, or spec out new features for Pulse's phishing triage system. It is written to be self-contained and information-dense rather than narrative — concrete function/table/route names are included so an LLM can reason about extension points without guessing. Repo file paths are given as provenance (
« path »), not as something the reading model needs to fetch.System: Pulse — Wulf Consulting's internal PSA dashboard (Next.js 16 + PostgreSQL, syncs Autotask PSA data). This document covers one subsystem: v3.0 Phishing Triage Automation, shipped 2026-07-17, 9 phases / 30 plans / 69 tasks, all 38 requirements complete, refined by 5 post-ship fixes through 2026-07-18.
1. Core value proposition
A security operator sees every phishing/spam-report ticket automatically triaged, deduplicated into campaigns, and classified — with any destructive remediation gated behind explicit human approval. Nothing in this system currently executes a real destructive action against a mail or identity provider; every remediation action this milestone is proposed-and-simulated only.
Trigger sources: Autotask tickets created by (a) KnowBe4 phish-alert-button reports, (b) Microsoft's built-in "Report Message" flow forwarded into a mailbox that syncs to Autotask, (c) manual employee spam/phishing complaints.
Pipeline (single sentence): detect ticket → extract .eml evidence → look up
Mimecast blast radius → deterministically classify → group into a campaign → propose
remediation behind human approval → post sanitized Autotask note → surface on a
ticket-ID-addressable review page reachable via a real Autotask LiveLink button →
optionally automate per-client via an opt-in gate.
2. Explicit non-goals (this milestone) — deferred to a future "v2"
These are known-missing, not accidentally missing — a future feature request in this space should assume these don't exist yet:
- No real remediation execution. No code path blocks a sender in Mimecast, deletes/
moves a Graph mailbox item, purges via Defender/Exchange, revokes a session, or resets
a password.
REMEDEXEC-01..05in the original requirements doc name these explicitly as out-of-scope-for-v1. - No URL reputation/sandbox lookup (
ENRICH-01) — URLs are extracted and displayed, never scored or detonated. - No LLM-backed classification (
ENRICH-02) — the classifier is 100% deterministic code; an LLM layer was deliberately reserved as a future plug-in behind the same interface, not built. - No automatic ticket closure, no auto-created parent incident, no auto-closed duplicate tickets.
- No backfill of ~267 pre-existing phishing tickets that predate this system — detection is forward-only from ship date.
- No per-tenant Mimecast client by default — a single global Mimecast client serves all companies (a documented v1 limitation; one post-ship quick task added optional per-tenant client injection, but the automatic/webhook path still uses the global client).
3. Data model
« migrations/097_phishing_triage_schema.sql, 098, 099_indicators_metadata.sql, 100_phishing_automation_gate.sql »
| Table | Purpose | Key columns |
|---|---|---|
campaigns |
One row per grouped phishing/spam incident | id, campaign_key, group_method, first_seen_at, last_seen_at, report_count, status (default open) |
reports |
One row per detected ticket | id, ticket_id (FK, UNIQUE), ticket_number, company_id, company_name, requester_contact_id, title, description, matched_patterns JSONB, content_hash, evidence JSONB, campaign_id (FK, nullable) |
messages |
Parsed .eml output, one per report |
id, report_id (FK), message_id, headers/urls/attachments JSONB, body_preview, raw_ref (B2 object key or null) |
indicators |
Per-attachment-hash / per-URL / per-sender correlation signals | id, message_id (FK), indicator_type, value, metadata JSONB |
classifications |
Append-only classify-call history | id, campaign_id (FK), verdict, confidence NUMERIC, summary, reasons JSONB, recommended_actions JSONB, requires_approval BOOLEAN |
remediation_actions |
Proposed/approved/completed remediation state | id, campaign_id (FK), action_type, status (default proposed), params JSONB, approved_by, approved_at |
audit_events |
Single append-only audit trail for every state change | id, campaign_id, actor, event_type, payload JSONB, created_at |
phishing_automation_gate |
Per-company opt-in automation flags | company_id (PK/FK), auto_parse/auto_classify/auto_report BOOLEAN (default false), updated_by, updated_at |
sync_schedules gains a seeded row id='phishing-sweep', cron 0 5 * * *, is_enabled=false.
Verdict vocabulary: SPAM | UNWANTED | THREAT | USER_AWARENESS (the last added in
Phase 23, for confirmed simulation-vendor reports).
Action vocabulary (7 types): each maps to a destructive/non-destructive flag; only
acknowledge_user is non-destructive (customer-visible "thanks for reporting" note) and
is the sole action type an automation gate may auto-post without human approval.
4. API surface
All /api/phishing/* routes are requirePermission('phishing', <action>)-gated
(read | analyze | approve | remediate). All /api/admin/phishing-automation/* routes
are requireAdmin()-gated.
| Route | Method | Purpose |
|---|---|---|
/api/phishing/campaigns |
GET | Paginated campaign list |
/api/phishing/campaigns/{id} |
GET | Full nested detail: reports/messages/indicators/classifications/remediation_actions/audit_events/timeline + fresh blast-radius |
/api/phishing/campaigns/{id}/classify |
POST | On-demand classify + audit write |
/api/phishing/campaigns/{id}/approve |
POST | Approve one or more recommended actions with param overrides |
/api/phishing/campaigns/{id}/remediate |
POST | Execute (simulated) previously-approved actions, idempotent |
/api/phishing/campaigns/{id}/mark-false-positive |
POST | Mark false-positive; 409 if any action already approved/completed |
/api/phishing/campaigns/{id}/mark-accidental-report |
POST | Parallel resolution path + customer note (added post-ship) |
/api/phishing/campaigns/{id}/triage-note |
POST | On-demand internal triage-note generation/post |
/api/phishing/reports/{report_id} |
GET | Standalone (ungrouped) report evidence + blast radius |
/api/phishing/tickets/{ticket_id}/campaign |
GET | Ticket→campaign resolver — always 200, {found:false} instead of 404 (so an Autotask LiveLink never dead-ends) |
/api/phishing/tickets/{ticket_id}/analyze |
POST | On-demand detect→parse→group for one ticket |
/api/admin/phishing-automation |
GET | List all companies + automation-gate flags |
/api/admin/phishing-automation/{companyId} |
PATCH | Upsert a company's 3 gate flags |
/api/admin/phishing-automation/{companyId} |
DELETE | Revert to all-off default |
5. UI surface
app/phishing/page.tsx— campaign list (nav entry).app/phishing/tickets/[ticketId]/page.tsx— the real review surface + production- confirmed live Autotask LiveLink target. 5-state machine:loading | not-triaged | ungrouped | ready | error. Composed of:ClassificationCard— verdict badge, confidence %, summary, reasons, recommended actions, permission-gated reclassify.ActionAreaCard— the only write-surface: checkbox-per-action approve with editable param overrides, confirm-dialog remediate/mark-false-positive/mark-accidental-report. Buttons always mounted, disabled+tooltipped when unauthorized or already resolved.EvidenceCard— tabbed Headers (SPF/DKIM/DMARC) / URLs (inert<code>text with copy-to-clipboard, never a clickable link — nodangerouslySetInnerHTMLanywhere in this feature) / Attachments (metadata only) / Body preview / Blast Radius (explicitunavailablebranch).TimelineCard— server-pre-sorted chronological feed.
app/admin/phishing-automation/page.tsx— company table, 3 independentSwitchtoggles per row (parse/classify/report).- Nav entry "Phishing" visible to all roles (everyone holds
phishing:read).
6. Architecture decisions worth knowing before proposing new features
| # | Decision | Rationale |
|---|---|---|
| 1 | Zero-LLM deterministic classifier | Avoids attacker-controlled email content ever reaching a model prompt (prompt-injection surface). Any future AI-assisted classification should sit behind the existing verdict interface, not replace it, unless the injection risk is separately solved. |
| 2 | Content-hash idempotency = SHA-256(title + description) only | Deliberately excludes status/assignee/timestamp churn. Evidence (notes/attachments) still refreshes every rescan even on an unchanged hash. |
| 3 | 3-tier .eml selection |
rfc.eml exact name → single non-wrapper message/rfc822 candidate → OriginatingEmail.eml fallback. Content-type alone can't disambiguate (empirically confirmed against 15 real tickets). |
| 4 | Hand-rolled SPF/DKIM/DMARC tokenizer | The obvious library (mailauth) forces live DNS/BIMI-HTTP lookups — violates the "never fetch anything from message content" invariant that runs through this whole feature. |
| 5 | 3-tier campaign grouping key | Message-ID → attachment-hash/URL-domain + subject + sender + 24h window → sender + normalized-subject + client + 24h window. Campaigns are never merged after creation — explicitly out of scope. |
| 6 | Mimecast blast-radius never throws | Unconditional parallel fan-out (delivered/held/threat-event lookups), 5-min Redis cache, degrades to an explicit {status:'unavailable', reason:...} rather than blocking the pipeline. |
| 7 | THREAT requires delivery AND a malicious signal | An auth failure alone on a fully-held message (reached nobody) stays UNWANTED. Malicious signal = hard SPF/DKIM/DMARC fail OR the same attachment-hash/URL recurring across ≥2 reports in the campaign — no external reputation service involved. |
| 8 | Simulation-vendor allowlist is a TypeScript constant | Not a DB table — every rule stays unit-testable pure code. Exact-domain-or-proper-subdomain match only, never substring, to resist lookalike-domain spoofing. |
| 9 | Confidence = point-deduction from 1.0 | Each deduction names the specific missing evidence (no parsed message / no Mimecast data / no indicators) rather than an opaque score — this is a literal requirement (CLASSIFY-03), not incidental. |
| 10 | All remediation this milestone is simulated | No block/purge/reset methods exist yet on any integration client — a real "actually block this sender" feature is new integration work, not a flag flip. |
| 11 | acknowledge_user is the one auto-postable, non-destructive action |
Everything else requires human approval by design; a new "auto-post X" feature should default to requiring the same explicit per-client opt-in Phase 23 established. |
| 12 | Per-company automation = 3 independent booleans, default off | auto_parse / auto_classify / auto_report. Report meaningfully depends on classify, which depends on parse (informational ordering only, not enforced in code). |
| 13 | URLs are inert, copy-only text in the operator UI | Stricter than the triage-note's sanitize-and-describe approach because this is an interactive page a human could click into. |
| 14 | Client-side permission checks reuse the exact server permission function | No bespoke role === 'admin' string checks anywhere in this feature — server remains sole enforcement boundary. |
7. Known gaps / tech debt (don't silently rediscover these)
- Phase 22 (Approval UI) has 5 outstanding manual browser click-through checks never run: full state-machine walkthrough, approve/remediate/mark-false-positive end-to-end with re-render, non-privileged role gating, Mimecast-unavailable rendering, URL inertness/clipboard-copy visual confirmation. Code is verified; UI is not yet browser-hardened.
- No retry if a webhook-triggered ticket row isn't yet in Postgres when detection runs — only a warning log; the backstop cron sweep ships disabled by default.
- Webhook doesn't re-trigger detection on ticket update, only create (deliberate).
- No floor (
GREATEST(..., 0)) on the campaign report-count decrement; a pre-existing TOCTOU race in the "already grouped" check. - The THREAT-tier "clicked > 0" escalation trigger was a reasoned proposal from research, never formally re-confirmed as a locked decision.
- The automatic (webhook/cron) path only ever reaches Tier-3 (weakest) campaign grouping until an operator has manually analyzed at least one report in that campaign — the EML parser is only invoked from the on-demand analyze endpoint, decision #5/#3 above.
- Global (not per-tenant) Mimecast client remains the default for the automatic path.
- 2 pre-existing, unrelated failing tests (
itglue-search.test.ts) — confirmed to predate this feature entirely, not phishing-related.
3 real defects already found and fixed (useful precedent — similar bug classes are worth checking for in any new feature touching the same code):
- Webhook trigger branching on an Autotask payload field that real webhooks never populate (fixed by reading the ticket back from Postgres instead of trusting payload shape).
- Re-analyzing a single-report campaign created a duplicate campaign row with an identical grouping key (a "no sibling match found" edge case fell through to "create new" instead of "keep existing").
- Every additional report accumulating into an already-classified simulation campaign re-triggered a duplicate customer-visible note (no idempotency guard on the auto-post path, and — found in review of that very fix — the parallel manual approval path had no equivalent guard either).
5 post-ship refinements (2026-07-16 → 2026-07-18): Mimecast blast-radius date-window
- silently-swallowed-error fix; a confidence-percentage display bug (1.0 confidence showed as "1%"); expanded simulation-vendor allowlist + parse-idempotency + auto-parse timing fix; new "mark as accidental report" action; Mimecast held-message false-positive fix (unbounded hold-queue lookup inflating blast-radius counts).
8. Entry points (for a human engineer, if this context is being used to spec work
that will land back in this repo)
« lib/services/phishing-detector.ts »— detection front door.« lib/services/campaign-grouping-service.ts »— trickiest logic; has the most bug history (see gap #2 above).« lib/services/campaign-classifier.ts »— deterministic rule engine.« app/phishing/tickets/[ticketId]/page.tsx »+« components/phishing/* »— operator-facing review surface.« lib/services/phishing-automation-gate.ts »+« app/admin/phishing-automation/page.tsx »— per-client automation on/off switch.« docs/mimecast-api-guide.md »— documents the held-message-queue date-range requirement behind one of the post-ship fixes above.
9. Good directions to brainstorm from here
Given the explicit non-goals above, natural "what's next" conversation starters:
- Real remediation execution (
REMEDEXEC-01..05): actually calling Mimecast block, Graph mailbox purge/move, Defender/Exchange purge, behind the existing approve-gate — the approval/audit/idempotency scaffolding already exists and is designed to have a real executor slotted in later. - URL reputation enrichment (
ENRICH-01): a scoring layer over the already-extracted, already-deduped indicator URLs. - An LLM-assisted classification layer (
ENRICH-02), explicitly designed to sit behind the current deterministic verdict interface as an optional enhancement/second opinion — not a replacement, given decision #1's prompt-injection rationale. - Per-tenant Mimecast client as the default, not just an optional override, closing gap "Global (not per-tenant) Mimecast client."
- Automatic campaign grouping parity with the on-demand path — closing the Tier-3-only limitation on the fully automatic pipeline.
- Historical backfill of the ~267 pre-existing phishing tickets, if retroactive visibility becomes valuable.