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
This commit is contained in:
parent
aabf5322e9
commit
15d0caa20d
1 changed files with 188 additions and 1 deletions
|
|
@ -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<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
|
||||
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
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue