---
phase: 15-data-model-detection-ticket-evidence
plan: 02
type: execute
wave: 2
depends_on: ["15-01"]
files_modified:
  - lib/services/phishing-detector.ts
  - lib/services/phishing-detector.test.ts
autonomous: true
requirements: [DETECT-01, DETECT-02, EVID-01]
must_haves:
  truths:
    - "Given a ticket whose title/description contains any of the 8 known patterns, the matcher flags it and reports which patterns matched"
    - "Given a ticket with none of the patterns, the matcher does not flag it"
    - "The content hash is stable for identical title+description and changes when either changes"
    - "Detecting a flagged ticket persists exactly one reports row with EVID-01 evidence"
    - "Re-detecting an unchanged ticket does not create or duplicate a reports row; a ticket whose title/description changed IS reprocessed"
  artifacts:
    - path: "lib/services/phishing-detector.ts"
      provides: "KNOWN_PHISHING_PATTERNS, matchesPhishingPatterns, computePhishingContentHash, gatherTicketEvidence, detectPhishingTicket"
      min_lines: 120
    - path: "lib/services/phishing-detector.test.ts"
      provides: "unit tests for matcher + content-hash covering all 8 patterns, negative case, and hash stability/change"
  key_links:
    - from: "detectPhishingTicket"
      to: "reports table (Plan 01)"
      via: "ON CONFLICT (ticket_id) upsert guarded by content_hash comparison"
      pattern: "ON CONFLICT \\(ticket_id\\)"
    - from: "computePhishingContentHash"
      to: "reports.content_hash"
      via: "sha256 over title+description (D-04)"
      pattern: "createHash\\('sha256'\\)"
---

<objective>
Build `lib/services/phishing-detector.ts` — the shared detection core called by both
the webhook path and the cron sweep (wired in Plan 03). It matches a ticket's
title+description against the 8 locked DETECT-01 patterns, computes a content hash for
D-04 idempotency, gathers EVID-01 evidence, and upserts a single `reports` row per
candidate ticket — reprocessing only when the content hash changed.

Purpose: One deterministic, testable detector with no duplicated matching logic
(CONTEXT.md discretion: "both call the same underlying logic").
Output: The detector service + a unit test file for its pure logic.
</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/15-data-model-detection-ticket-evidence/15-CONTEXT.md
@.planning/phases/15-data-model-detection-ticket-evidence/15-PATTERNS.md
@.planning/phases/15-data-model-detection-ticket-evidence/15-01-SUMMARY.md

<interfaces>
<!-- Exact contracts the detector uses. Do not re-explore. -->

reports table (Plan 01): id UUID, ticket_id BIGINT NOT NULL UNIQUE, ticket_number,
company_id, company_name, requester_contact_id, created_by_contact_id, title, description,
matched_patterns JSONB, content_hash TEXT NOT NULL, evidence JSONB, campaign_id UUID (null here),
created_at, updated_at. Upsert target: ON CONFLICT (ticket_id).

postgresClient singleton (lib/services/postgres-client.ts): postgresClient.query(sql, params)
returns { rows, rowCount }. Always use parameterized $1/$2 placeholders.

Evidence source tables (already synced, join by ticket_id):
- ticket_notes (migration 025): id, ticket_id BIGINT, title, description, note_type INTEGER, creator_resource_id, created_at
- time_entries (migration 006): id, resource_id, ticket_id, entry_date, hours_worked, start_date_time, end_date_time
- companies: id, company_name (join tickets.company_id → companies.id for company_name)

AutotaskClient.getAttachments(entityName, entityId): Promise<Attachment[]>
(lib/services/autotask-client.ts:424) — Attachment metadata fields: fullPath, title, contentType.
Persist metadata only (fullPath, title, contentType) — NOT `data` (base64); content fetch is Phase 16.
Instantiate the client the same way ticket-reconciliation-service.ts:34-45 does (env-var config,
lazy singleton), or reuse an existing factory if one is present.

Pattern-matching analog: lib/services/robotic-classifier.ts:206-219 (evaluateContains —
case-insensitive substring via .toLowerCase() + .includes(); NO regex, NO eval).
Content-hash analog: lib/services/analyzer/preprocessor.ts:251-266 (computeContentHash —
createHash('sha256') over canonical JSON). Import `createHash` from node 'crypto'.

The 8 locked DETECT-01 patterns (case-insensitive substrings — all must be covered):
"Phishing Report", "Spam Alert", "Phishing Alert - Email Security Report",
"KnowBe4 Phish Alert Report", "Source: KnowBe4 Phish Alert Button",
"userSubmissionsReportMessage", "reported message destinations", "Microsoft directly".
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Pure detection logic — pattern matcher + content hash (TDD)</name>
  <files>lib/services/phishing-detector.ts, lib/services/phishing-detector.test.ts</files>
  <behavior>
    - matchesPhishingPatterns("Fwd: Phishing Report", null) → { flagged: true, matched: ["Phishing Report"] }
    - matchesPhishingPatterns("Re: order confirmation", "please review invoice") → { flagged: false, matched: [] }
    - Matching is case-insensitive: matchesPhishingPatterns("SPAM ALERT from user", null).flagged === true
    - Each of the 8 locked patterns individually triggers a flag when present in title OR description
    - matched[] contains exactly the patterns that were present (subset of KNOWN_PHISHING_PATTERNS)
    - computePhishingContentHash("t","d") === computePhishingContentHash("t","d") (stable)
    - computePhishingContentHash("t","d") !== computePhishingContentHash("t2","d") (title change)
    - computePhishingContentHash("t","d") !== computePhishingContentHash("t","d2") (description change)
    - computePhishingContentHash("t", null) is defined and stable (null description normalizes)
  </behavior>
  <read_first>
    - lib/services/robotic-classifier.ts (evaluateContains, lines 206-219 — case-insensitive substring shape to copy)
    - lib/services/analyzer/preprocessor.ts (computeContentHash, lines 233-266 — sha256 shape to mirror, hashing only title+description)
    - vitest.config.ts (confirm include glob `lib/**/*.test.ts` picks up the new test file)
    - .planning/phases/15-data-model-detection-ticket-evidence/15-PATTERNS.md (detector section — matcher + content-hash requirements)
  </read_first>
  <action>
    Write the tests in `lib/services/phishing-detector.test.ts` FIRST (RED), covering
    every behavior listed above — one assertion per locked pattern (all 8), the negative
    case, case-insensitivity, and hash stability/change/null-normalization. Run vitest and
    confirm the suite fails because the functions don't exist yet.

    Then create `lib/services/phishing-detector.ts` and implement (GREEN):
    - `export const KNOWN_PHISHING_PATTERNS: readonly string[]` — the 8 locked strings
      exactly as given in the interfaces block above.
    - `export function matchesPhishingPatterns(title: string | null, description: string | null):
      { flagged: boolean; matched: string[] }` — build a combined `${title ?? ''} ${description ?? ''}`
      lowercased haystack, return matched = patterns whose lowercased form is a substring,
      flagged = matched.length > 0. Use `.toLowerCase()` + `.includes()` only — NO regex, NO eval
      (matches robotic-classifier.evaluateContains).
    - `export function computePhishingContentHash(title: string | null, description: string | null):
      string` — `createHash('sha256').update(JSON.stringify({ title: title ?? '', description: description ?? '' })).digest('hex')`.
      Do NOT include last_activity_date, status, or any bump-prone field (D-04).

    Run vitest again and confirm GREEN. Do not add DB access in this task — pure functions only.
  </action>
  <verify>
    <automated>npx vitest run lib/services/phishing-detector.test.ts && npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - `lib/services/phishing-detector.test.ts` contains at least 8 assertions covering each locked pattern by name, plus the negative case, case-insensitivity, and hash stability/change
    - `npx vitest run lib/services/phishing-detector.test.ts` passes with 0 failures
    - `KNOWN_PHISHING_PATTERNS` array has exactly 8 entries matching the locked strings verbatim
    - `matchesPhishingPatterns` uses `.toLowerCase()` + `.includes()` (no `RegExp`, no `eval`) — grep confirms no `RegExp(`/`eval(` in the file
    - `computePhishingContentHash` uses `createHash('sha256')` and hashes only title+description
    - `npx tsc --noEmit --pretty` passes
  </acceptance_criteria>
  <done>Matcher and content-hash are implemented as pure functions, all 8 patterns are covered by passing tests, and type-check is clean.</done>
</task>

<task type="auto">
  <name>Task 2: Evidence capture + detectPhishingTicket orchestration with D-04 idempotency</name>
  <files>lib/services/phishing-detector.ts</files>
  <read_first>
    - lib/services/phishing-detector.ts (current state after Task 1 — extend, don't rewrite)
    - lib/services/ticket-reconciliation-service.ts (lazy AutotaskClient singleton pattern lines 34-45; per-row try/catch + result-shape convention)
    - lib/services/analyzer/persistence.ts (findExistingAnalysisByContentHash, lines 83-102 — check-before-write idempotency shape to mirror)
    - lib/services/autotask-client.ts (getAttachments signature, lines 424-436; Attachment metadata fields)
    - lib/services/postgres-client.ts (query/upsert API + parameterized-query convention)
  </read_first>
  <action>
    Extend `lib/services/phishing-detector.ts` with evidence capture and the shared
    orchestration entry point. Add:

    - `interface DetectableTicket { id: number; ticket_number: string | null; title: string | null;
      description: string | null; company_id: number | null; contact_id?: number | null;
      created_by_contact_id?: number | null }` — the minimal input both callers pass.

    - `async function gatherTicketEvidence(ticket: DetectableTicket): Promise<EvidencePayload>` —
      capture EVID-01 evidence into a JSON object:
      * company_name: SELECT company_name FROM companies WHERE id = ticket.company_id (null-safe)
      * notes: SELECT id, title, description, note_type, creator_resource_id, created_at FROM ticket_notes
        WHERE ticket_id = $1 ORDER BY created_at (parameterized)
      * time_entries: SELECT id, resource_id, entry_date, hours_worked, start_date_time, end_date_time
        FROM time_entries WHERE ticket_id = $1 ORDER BY entry_date (parameterized)
      * attachments: call AutotaskClient.getAttachments('Tickets', ticket.id), map to metadata only
        ({ fullPath, title, contentType }) — DROP `data`/base64. Wrap the Autotask call in try/catch
        so an attachment-API failure degrades to an empty attachments array and logs, never throws out
        of detection. Define EvidencePayload with fields: company_name, notes[], time_entries[], attachments[].

    - `export async function detectPhishingTicket(ticket: DetectableTicket):
      Promise<{ flagged: boolean; reportId?: string; skippedUnchanged?: boolean }>`:
      1. const { flagged, matched } = matchesPhishingPatterns(ticket.title, ticket.description);
         if (!flagged) return { flagged: false }.
      2. const contentHash = computePhishingContentHash(ticket.title, ticket.description).
      3. SELECT id, content_hash FROM reports WHERE ticket_id = $1 (parameterized). If a row exists and
         its content_hash === contentHash, return { flagged: true, skippedUnchanged: true } WITHOUT
         re-gathering evidence or writing (D-04 — status/assignee/last_activity bumps don't change the hash).
      4. Otherwise gather evidence, then upsert:
         INSERT INTO reports (ticket_id, ticket_number, company_id, company_name, requester_contact_id,
         created_by_contact_id, title, description, matched_patterns, content_hash, evidence)
         VALUES ($1..$11) ON CONFLICT (ticket_id) DO UPDATE SET ... , updated_at = NOW()
         RETURNING id. matched_patterns and evidence bind as JSON (JSON.stringify or ::jsonb cast).
         Column bindings from the DetectableTicket: `requester_contact_id` binds from `ticket.contact_id`
         (the Autotask requester/reporter of the ticket), and `created_by_contact_id` binds from
         `ticket.created_by_contact_id`. company_name comes from the gathered evidence's company_name.
         Return { flagged: true, reportId }.

    Do not duplicate the matcher/hash logic — call the Task 1 functions. Use console.error with a
    `[PHISHING-DETECT]` prefix for caught errors, matching the project logging convention.
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty && npx vitest run lib/services/phishing-detector.test.ts && grep -q "ON CONFLICT (ticket_id)" lib/services/phishing-detector.ts && echo "orchestration OK"</automated>
  </verify>
  <acceptance_criteria>
    - `detectPhishingTicket` is exported and returns `{ flagged, reportId?, skippedUnchanged? }`
    - Idempotency branch present: a SELECT of reports by ticket_id compares stored `content_hash` to the freshly computed hash and returns `skippedUnchanged: true` without writing when equal
    - The reports upsert uses `ON CONFLICT (ticket_id) DO UPDATE ... updated_at = NOW()` and RETURNING id
    - `requester_contact_id` is bound from `ticket.contact_id` and `created_by_contact_id` from `ticket.created_by_contact_id` in the INSERT
    - Evidence gathering reads ticket_notes and time_entries via parameterized `$1` queries (no string interpolation of ticket_id) and stores only attachment metadata (fullPath/title/contentType), never base64 `data`
    - The Autotask getAttachments call is wrapped so a failure yields an empty attachments array instead of throwing
    - `npx tsc --noEmit --pretty` passes and the Task 1 vitest suite still passes
  </acceptance_criteria>
  <done>detectPhishingTicket matches, hashes, checks the content-hash idempotency guard, gathers EVID-01 evidence, and upserts one reports row per candidate — reprocessing only on hash change.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| ticket title/description → matcher + hash | Untrusted ticket content flows into substring match and sha256 |
| ticket content → Postgres reports row | Untrusted content written to DB |
| Autotask Attachments API → evidence | External API metadata captured into evidence JSON |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-15-04 | Tampering (SQL injection) | reports upsert + evidence SELECTs from ticket content | mitigate | All DB access via postgresClient parameterized `$1/$2` placeholders — ticket content is never string-interpolated into SQL (acceptance criterion enforces this) |
| T-15-05 | Elevation / code exec via pattern input | matchesPhishingPatterns over attacker-controlled title | mitigate | Matching is `.toLowerCase()` + `.includes()` only — no `RegExp` construction (no ReDoS), no `eval` (grep-enforced) |
| T-15-06 | Denial of Service | crafted content re-triggering repeated processing | mitigate | D-04 content-hash guard: unchanged content skips before any evidence gathering or write; hash excludes bump-prone fields so status/assignee churn cannot force reprocessing |
| T-15-07 | Server-Side Request Forgery / unsafe fetch | attachment handling | mitigate | Only attachment metadata (fullPath/title/contentType) is stored; the detector never fetches attachment `data`, expands, or executes any URL. Autotask call wrapped in try/catch so it degrades, never blocks |
| T-15-08 | Information Disclosure | evidence JSON holds ticket notes/time entries | accept | No new external read surface in this phase (ACCESS-01 auth gate lands in Phase 18); data already resident in Postgres |
| T-15-SC | Tampering | package installs | accept | No new npm packages — uses built-in `crypto`, existing `pg` client and AutotaskClient. No install task |
</threat_model>

<verification>
- `npx vitest run lib/services/phishing-detector.test.ts` — all 8 pattern cases + negative + hash tests pass
- `npx tsc --noEmit --pretty` clean
- `grep -E "RegExp\(|eval\(" lib/services/phishing-detector.ts` returns nothing
- `grep "ON CONFLICT (ticket_id)" lib/services/phishing-detector.ts` matches
- All reports/notes/time_entries SQL uses `$1`/`$2` parameters (no interpolated ticket_id)
</verification>

<success_criteria>
The detector flags tickets matching the 8 known patterns and persists a reports row
(ROADMAP SC#2), reprocesses only when the ticket's title/description content hash changed
(SC#3, D-04), and each reports row stores EVID-01 evidence — company, requester/reporter,
title, description, notes, time entries, and attachment metadata (SC#4).
</success_criteria>

<output>
Create `.planning/phases/15-data-model-detection-ticket-evidence/15-02-SUMMARY.md` when done.
</output>
