wulf-pulse/lib/services/phishing-sweep-service.ts
lorentz 19b8b4b415 feat(18-02): wire groupReportIntoCampaign into webhook + cron sweep paths
- webhook-service.ts: triggerPhishingDetection calls groupReportIntoCampaign
  with skipIfAlreadyGrouped:true after a flagged detection (D-01, D-08)
- phishing-sweep-service.ts: per-ticket sweep loop calls the same, inside the
  existing try/catch so a grouping failure counts against result.errors
  without aborting the sweep
2026-07-15 19:31:18 -04:00

109 lines
3.7 KiB
TypeScript

/**
* 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 { groupReportIntoCampaign } from './campaign-grouping-service';
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;
}
// D-01/D-08: grouping runs regardless of skippedUnchanged (a report
// could have been created by a previous sweep pass and still lack a
// campaign_id if grouping failed transiently that time); short-circuits
// internally if already grouped.
if (detection.flagged && detection.reportId) {
await groupReportIntoCampaign(detection.reportId, { skipIfAlreadyGrouped: true });
}
} 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;
}