ticket_notes and time_entries both carry an is_deleted soft-delete flag (per CLAUDE.md audit-column convention); gatherTicketEvidence was reading both without filtering it, so retracted notes and reversed time entries showed up as evidence for every phishing report. Found during code-review re-verification of the Phase 15 CR-01/WR-01/WR-02 fixes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012wWroM6FXkQJiH3JgYcony
249 lines
8.3 KiB
TypeScript
249 lines
8.3 KiB
TypeScript
/**
|
|
* Phishing Detector
|
|
*
|
|
* 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,
|
|
* 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
|
|
* logic").
|
|
*/
|
|
|
|
import { createHash } from 'crypto';
|
|
import { postgresClient } from './postgres-client';
|
|
import { getAutotaskClient } from './autotask-factory';
|
|
|
|
// =============================================================================
|
|
// Pure detection logic — pattern matcher + content hash
|
|
// =============================================================================
|
|
|
|
/**
|
|
* The 8 locked DETECT-01 patterns (case-insensitive substring match).
|
|
*/
|
|
export const KNOWN_PHISHING_PATTERNS: readonly string[] = [
|
|
'Phishing Report',
|
|
'Spam Alert',
|
|
'Phishing Alert - Email Security Report',
|
|
'KnowBe4 Phish Alert Report',
|
|
'Source: KnowBe4 Phish Alert Button',
|
|
'userSubmissionsReportMessage',
|
|
'reported message destinations',
|
|
'Microsoft directly',
|
|
];
|
|
|
|
/**
|
|
* Case-insensitive substring match against the locked pattern list — mirrors
|
|
* robotic-classifier.evaluateContains (.toLowerCase() + .includes() only,
|
|
* NO regex, NO eval).
|
|
*/
|
|
export function matchesPhishingPatterns(
|
|
title: string | null,
|
|
description: string | null
|
|
): { flagged: boolean; matched: string[] } {
|
|
const haystack = `${title ?? ''} ${description ?? ''}`.toLowerCase();
|
|
const matched = KNOWN_PHISHING_PATTERNS.filter((pattern) =>
|
|
haystack.includes(pattern.toLowerCase())
|
|
);
|
|
return { flagged: matched.length > 0, matched };
|
|
}
|
|
|
|
/**
|
|
* sha256 over title+description only (D-04) — does NOT include
|
|
* last_activity_date, status, or any bump-prone field, so status/assignee
|
|
* churn never forces reprocessing.
|
|
*/
|
|
export function computePhishingContentHash(
|
|
title: string | null,
|
|
description: string | null
|
|
): string {
|
|
return createHash('sha256')
|
|
.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[];
|
|
}
|
|
|
|
/**
|
|
* 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<EvidencePayload> {
|
|
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<EvidenceNote>(
|
|
`SELECT id, title, description, note_type, creator_resource_id, created_at
|
|
FROM ticket_notes
|
|
WHERE ticket_id = $1
|
|
AND is_deleted = false
|
|
ORDER BY created_at`,
|
|
[ticket.id]
|
|
);
|
|
|
|
const timeEntriesResult = await postgresClient.query<EvidenceTimeEntry>(
|
|
`SELECT id, resource_id, entry_date, hours_worked, start_date_time, end_date_time
|
|
FROM time_entries
|
|
WHERE ticket_id = $1
|
|
AND is_deleted = false
|
|
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<DetectPhishingResult> {
|
|
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) {
|
|
// Content unchanged (D-04): skip full reprocessing (title/description/
|
|
// matched_patterns/content_hash rewriting would be a no-op anyway).
|
|
// But the evidence snapshot (notes, time entries, attachments) can
|
|
// still drift after the first detection — an analyst can add a note,
|
|
// log time, or attach a file without touching title/description. Refresh
|
|
// the evidence snapshot on every call regardless of the content-hash
|
|
// gate so later phases never read a stale snapshot.
|
|
const refreshedEvidence = await gatherTicketEvidence(ticket);
|
|
await postgresClient.query(
|
|
`UPDATE reports SET evidence = $1::jsonb, updated_at = NOW() WHERE ticket_id = $2`,
|
|
[JSON.stringify(refreshedEvidence), ticket.id]
|
|
);
|
|
return { flagged: true, reportId: existing.rows[0].id, 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;
|
|
}
|
|
}
|