feat(15-03): add bounded phishing sweep service
- sweepPhishingTickets() queries recently-modified tickets (7d window, LIMIT 500) - delegates each ticket to shared detectPhishingTicket (no duplicated match/hash logic) - per-row try/catch increments errors without aborting the loop
This commit is contained in:
parent
191a8c7210
commit
dbd2ebe63c
1 changed files with 101 additions and 0 deletions
101
lib/services/phishing-sweep-service.ts
Normal file
101
lib/services/phishing-sweep-service.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* Phishing Sweep Service
|
||||
*
|
||||
* Bounded cron reconciliation sweep over recently-modified tickets, mirroring
|
||||
* ticket-reconciliation-service.ts's structure. The webhook path (Plan 03,
|
||||
* webhook-service.ts) is the primary near-real-time trigger; this sweep
|
||||
* catches anything the webhook missed (dropped events, backfilled tickets,
|
||||
* tickets whose content changed after the webhook already fired) by
|
||||
* re-running the same shared detectPhishingTicket core.
|
||||
*
|
||||
* No duplicated pattern-matching/hashing logic here — this file only
|
||||
* queries candidate tickets and delegates detection to phishing-detector.ts.
|
||||
*/
|
||||
|
||||
import { postgresClient } from './postgres-client';
|
||||
import { detectPhishingTicket, type DetectableTicket } from './phishing-detector';
|
||||
import { createSyncLogger } from '../utils/sync-logger';
|
||||
|
||||
export interface PhishingSweepResult {
|
||||
scanned: number;
|
||||
flagged: number;
|
||||
skippedUnchanged: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
const SWEEP_WINDOW_DAYS = 7;
|
||||
const SCAN_LIMIT = 500;
|
||||
|
||||
/**
|
||||
* Re-scan up to SCAN_LIMIT recently-modified tickets through the shared
|
||||
* phishing detector. Bounded and idempotent — safe to run on a recurring
|
||||
* schedule alongside the webhook trigger.
|
||||
*/
|
||||
export async function sweepPhishingTickets(): Promise<PhishingSweepResult> {
|
||||
const logger = createSyncLogger({ component: 'PhishingSweep' });
|
||||
const startedAt = Date.now();
|
||||
const result: PhishingSweepResult = {
|
||||
scanned: 0,
|
||||
flagged: 0,
|
||||
skippedUnchanged: 0,
|
||||
errors: 0,
|
||||
};
|
||||
|
||||
const candidatesQuery = `
|
||||
SELECT id, ticket_number, title, description, company_id, contact_id, created_by_contact_id
|
||||
FROM tickets
|
||||
WHERE is_deleted = false
|
||||
AND last_activity_date > NOW() - INTERVAL '${SWEEP_WINDOW_DAYS} days'
|
||||
ORDER BY last_activity_date DESC
|
||||
LIMIT $1
|
||||
`;
|
||||
const candidates = await postgresClient.query<{
|
||||
id: string;
|
||||
ticket_number: string | null;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
company_id: number | null;
|
||||
contact_id: number | null;
|
||||
created_by_contact_id: number | null;
|
||||
}>(candidatesQuery, [SCAN_LIMIT]);
|
||||
|
||||
logger.info(
|
||||
`Sweeping ${candidates.rows.length} recently-modified tickets (>${SWEEP_WINDOW_DAYS}d, limit=${SCAN_LIMIT})`
|
||||
);
|
||||
|
||||
for (const row of candidates.rows) {
|
||||
result.scanned += 1;
|
||||
const ticket: DetectableTicket = {
|
||||
id: Number(row.id),
|
||||
ticket_number: row.ticket_number,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
company_id: row.company_id,
|
||||
contact_id: row.contact_id,
|
||||
created_by_contact_id: row.created_by_contact_id,
|
||||
};
|
||||
|
||||
try {
|
||||
const detection = await detectPhishingTicket(ticket);
|
||||
if (detection.skippedUnchanged) {
|
||||
result.skippedUnchanged += 1;
|
||||
} else if (detection.flagged) {
|
||||
result.flagged += 1;
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors += 1;
|
||||
logger.warn(
|
||||
`Phishing detection failed for ticket ${ticket.id}`,
|
||||
{ ticketId: ticket.id },
|
||||
err instanceof Error ? err : new Error(String(err))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Phishing sweep complete: scanned=${result.scanned} flagged=${result.flagged} skippedUnchanged=${result.skippedUnchanged} errors=${result.errors}`,
|
||||
{ duration: Date.now() - startedAt }
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue