From 15d0caa20de831eb190a04f8df85217742a89473 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 15 Jul 2026 07:42:59 -0400 Subject: [PATCH] feat(15-02): add evidence capture + detectPhishingTicket orchestration - gatherTicketEvidence: company_name, ticket_notes, time_entries (all parameterized $1 queries), and Autotask attachment metadata only (fullPath/title/contentType, never base64 data); Autotask call wrapped in try/catch so a failure degrades to an empty attachments array - detectPhishingTicket: matches, hashes, checks D-04 idempotency guard (skips re-gathering/writing when content_hash is unchanged), then upserts one reports row via ON CONFLICT (ticket_id) DO UPDATE ... RETURNING id - requester_contact_id binds from ticket.contact_id, created_by_contact_id from ticket.created_by_contact_id per interfaces contract --- lib/services/phishing-detector.ts | 189 +++++++++++++++++++++++++++++- 1 file changed, 188 insertions(+), 1 deletion(-) diff --git a/lib/services/phishing-detector.ts b/lib/services/phishing-detector.ts index 224860b..a88de0f 100644 --- a/lib/services/phishing-detector.ts +++ b/lib/services/phishing-detector.ts @@ -3,7 +3,9 @@ * * Shared detection core called by both the webhook path (Plan 03) and the * cron sweep (Plan 03). Matches a ticket's title+description against the 8 - * locked DETECT-01 patterns, computes a content hash for D-04 idempotency. + * 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. * * One deterministic, testable detector with no duplicated matching logic * between callers (CONTEXT.md discretion: "both call the same underlying @@ -11,6 +13,8 @@ */ import { createHash } from 'crypto'; +import { postgresClient } from './postgres-client'; +import { AutotaskClient } from './autotask-client'; // ============================================================================= // Pure detection logic — pattern matcher + content hash @@ -59,3 +63,186 @@ export function computePhishingContentHash( .update(JSON.stringify({ title: title ?? '', description: description ?? '' })) .digest('hex'); } + +// ============================================================================= +// Evidence capture + orchestration +// ============================================================================= + +export 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; +} + +interface EvidenceNote { + id: number; + title: string | null; + description: string | null; + note_type: number | null; + creator_resource_id: number | null; + created_at: string; +} + +interface EvidenceTimeEntry { + id: number; + resource_id: number | null; + entry_date: string | null; + hours_worked: number | null; + start_date_time: string | null; + end_date_time: string | null; +} + +interface EvidenceAttachment { + fullPath: string; + title: string; + contentType?: string; +} + +export interface EvidencePayload { + company_name: string | null; + notes: EvidenceNote[]; + time_entries: EvidenceTimeEntry[]; + attachments: EvidenceAttachment[]; +} + +let _autotaskClient: AutotaskClient | null = null; +function getAutotaskClient(): AutotaskClient { + if (!_autotaskClient) { + _autotaskClient = new AutotaskClient({ + apiUrl: process.env.AUTOTASK_API_URL || '', + username: process.env.AUTOTASK_USERNAME || '', + password: process.env.AUTOTASK_SECRET || '', + apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '', + }); + } + return _autotaskClient; +} + +/** + * Gather EVID-01 evidence for a candidate ticket: company name, ticket notes, + * time entries, and attachment metadata (never base64 `data` — content fetch + * is Phase 16). + */ +export async function gatherTicketEvidence( + ticket: DetectableTicket +): Promise { + const companyResult = await postgresClient.query<{ company_name: string | null }>( + `SELECT company_name FROM companies WHERE id = $1`, + [ticket.company_id] + ); + const company_name = companyResult.rows[0]?.company_name ?? null; + + const notesResult = await postgresClient.query( + `SELECT id, title, description, note_type, creator_resource_id, created_at + FROM ticket_notes + WHERE ticket_id = $1 + ORDER BY created_at`, + [ticket.id] + ); + + const timeEntriesResult = await postgresClient.query( + `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`, + [ticket.id] + ); + + let attachments: EvidenceAttachment[] = []; + try { + const rawAttachments = await getAutotaskClient().getAttachments('Tickets', ticket.id); + attachments = rawAttachments.map((attachment) => ({ + fullPath: attachment.fullPath, + title: attachment.title, + contentType: attachment.contentType, + })); + } catch (error) { + console.error('[PHISHING-DETECT] Failed to fetch attachments for ticket', ticket.id, error); + attachments = []; + } + + return { + company_name, + notes: notesResult.rows, + time_entries: timeEntriesResult.rows, + attachments, + }; +} + +export interface DetectPhishingResult { + flagged: boolean; + reportId?: string; + skippedUnchanged?: boolean; +} + +/** + * Shared entry point called by both the webhook path and the cron sweep. + * Matches, hashes, checks the D-04 idempotency guard, gathers EVID-01 + * evidence, and upserts one `reports` row per candidate ticket. + */ +export async function detectPhishingTicket( + ticket: DetectableTicket +): Promise { + const { flagged, matched } = matchesPhishingPatterns(ticket.title, ticket.description); + if (!flagged) { + return { flagged: false }; + } + + const contentHash = computePhishingContentHash(ticket.title, ticket.description); + + try { + const existing = await postgresClient.query<{ id: string; content_hash: string }>( + `SELECT id::text AS id, content_hash FROM reports WHERE ticket_id = $1`, + [ticket.id] + ); + + if (existing.rowCount && existing.rowCount > 0 && existing.rows[0].content_hash === contentHash) { + return { flagged: true, skippedUnchanged: true }; + } + + const evidence = await gatherTicketEvidence(ticket); + + const upsertResult = await postgresClient.query<{ id: string }>( + `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, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11::jsonb) + ON CONFLICT (ticket_id) DO UPDATE SET + ticket_number = EXCLUDED.ticket_number, + company_id = EXCLUDED.company_id, + company_name = EXCLUDED.company_name, + requester_contact_id = EXCLUDED.requester_contact_id, + created_by_contact_id = EXCLUDED.created_by_contact_id, + title = EXCLUDED.title, + description = EXCLUDED.description, + matched_patterns = EXCLUDED.matched_patterns, + content_hash = EXCLUDED.content_hash, + evidence = EXCLUDED.evidence, + updated_at = NOW() + RETURNING id::text AS id`, + [ + ticket.id, + ticket.ticket_number, + ticket.company_id, + evidence.company_name, + ticket.contact_id ?? null, + ticket.created_by_contact_id ?? null, + ticket.title, + ticket.description, + JSON.stringify(matched), + contentHash, + JSON.stringify(evidence), + ] + ); + + return { flagged: true, reportId: upsertResult.rows[0].id }; + } catch (error) { + console.error('[PHISHING-DETECT] Failed to detect/persist report for ticket', ticket.id, error); + throw error; + } +}