From 204276c88a7033fb7d013662a3fe470e5a1d2f96 Mon Sep 17 00:00:00 2001 From: lorentz Date: Fri, 17 Jul 2026 07:19:18 -0400 Subject: [PATCH 1/3] feat(quick-260717-a19): add 3 confirmed KnowBe4 domains to sim allowlist - Extend knowbe4 vendor entry with customer-portal.info, cloud-service-care.com, bankonlinesupport.com (confirmed via shared URL fingerprint across Seubert tickets 699419/699421/699422/699433/ 699435/699456 on 2026-07-16/17) - domainMatchesAllowlist and isKnownSimulationSender untouched - Add tests for exact match, subdomain match, and suffix-spoof rejection --- lib/services/campaign-classifier.test.ts | 14 ++++++++++++++ lib/services/campaign-classifier.ts | 13 +++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/lib/services/campaign-classifier.test.ts b/lib/services/campaign-classifier.test.ts index 2aaf44e..7295bb7 100644 --- a/lib/services/campaign-classifier.test.ts +++ b/lib/services/campaign-classifier.test.ts @@ -60,6 +60,20 @@ describe('domainMatchesAllowlist', () => { expect(domainMatchesAllowlist('it-support.care.attacker.net')).toBe(false); expect(domainMatchesAllowlist('evil-it-support.care')).toBe(false); }); + + it('matches the 3 KnowBe4 domains confirmed via Seubert ticket 699456 (260717-a19)', () => { + expect(domainMatchesAllowlist('customer-portal.info')).toBe(true); + expect(domainMatchesAllowlist('cloud-service-care.com')).toBe(true); + expect(domainMatchesAllowlist('bankonlinesupport.com')).toBe(true); + }); + + it('matches a subdomain of the newly-added customer-portal.info', () => { + expect(domainMatchesAllowlist('mail.customer-portal.info')).toBe(true); + }); + + it('does NOT match a suffix-spoofed variant of the newly-added domain', () => { + expect(domainMatchesAllowlist('customer-portal.info.attacker.net')).toBe(false); + }); }); describe('isKnownSimulationSender', () => { diff --git a/lib/services/campaign-classifier.ts b/lib/services/campaign-classifier.ts index 4a120e9..45c0b7b 100644 --- a/lib/services/campaign-classifier.ts +++ b/lib/services/campaign-classifier.ts @@ -33,8 +33,17 @@ import { getBlastRadius, type BlastRadiusResult } from './mimecast-blast-radius' export const KNOWN_SIMULATION_SENDERS: readonly { vendor: string; domains: readonly string[] }[] = [ { vendor: 'knowbe4', - // 219 tickets, ~37 impersonated personas — 19-RESEARCH.md D-07 finding #2 - domains: ['it-support.care'], + // 219 tickets, ~37 impersonated personas — 19-RESEARCH.md D-07 finding #2. + // customer-portal.info, cloud-service-care.com, bankonlinesupport.com + // confirmed via shared /render-template/ + /tracking/ URL fingerprint + // across Seubert tickets 699419/699421/699422/699433/699435/699456 on + // 2026-07-16/17 (quick task 260717-a19). + domains: [ + 'it-support.care', + 'customer-portal.info', + 'cloud-service-care.com', + 'bankonlinesupport.com', + ], }, { vendor: 'breach-secure-now', From cf04f07c58c158a4417e26eaaa9fd487437a5e94 Mon Sep 17 00:00:00 2001 From: lorentz Date: Fri, 17 Jul 2026 07:20:48 -0400 Subject: [PATCH 2/3] feat(quick-260717-a19): add idempotency guard + retry-parse on ticket.update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parseAndStoreMessage (Defect 3): short-circuit with { stored: false, reason: 'already-parsed' } when a messages row already exists for the report, before any Autotask attachment fetch - webhook-service (Defect 2): new retryPhishingParseOnUpdate wired into ticket.update fire-and-forget path; retries the missing-EML parse for a flagged, unparsed, auto_parse-gated report — no new cron/polling, reuses existing update traffic, safe to fire repeatedly thanks to the new idempotency guard - Adjust eml-service test mock default so the new leading existence-check query doesn't short-circuit existing happy-path tests; add new test for the already-parsed short-circuit --- lib/services/phishing-eml-service.test.ts | 28 +++++++++- lib/services/phishing-eml-service.ts | 14 +++++ lib/services/webhook-service.ts | 62 +++++++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/lib/services/phishing-eml-service.test.ts b/lib/services/phishing-eml-service.test.ts index 0fad974..d06443b 100644 --- a/lib/services/phishing-eml-service.test.ts +++ b/lib/services/phishing-eml-service.test.ts @@ -76,8 +76,16 @@ describe('parseAndStoreMessage', () => { isB2ConfiguredMock.mockReset(); presignUploadMock.mockReset(); - // Default: any INSERT ... RETURNING resolves with a single row. - queryMock.mockResolvedValue({ rows: [{ id: 'message-uuid-1' }], rowCount: 1 }); + // Default: any INSERT ... RETURNING resolves with a single row. The new + // leading `SELECT id FROM messages WHERE report_id` idempotency check + // (Defect 3, 260717-a19) must resolve empty by default so existing + // happy-path tests aren't short-circuited as 'already-parsed'. + queryMock.mockImplementation((sql: string) => { + if (String(sql).includes('FROM messages WHERE report_id')) { + return Promise.resolve({ rows: [], rowCount: 0 }); + } + return Promise.resolve({ rows: [{ id: 'message-uuid-1' }], rowCount: 1 }); + }); global.fetch = vi.fn().mockResolvedValue(new Response('ok', { status: 200 })); }); @@ -192,4 +200,20 @@ describe('parseAndStoreMessage', () => { }) ); }); + + it('returns { stored: false, reason: "already-parsed" } and does no work when a messages row already exists for the report (Defect 3, 260717-a19)', async () => { + queryMock.mockImplementation((sql: string) => { + if (String(sql).includes('FROM messages WHERE report_id')) { + return Promise.resolve({ rows: [{ id: 'existing-message-uuid' }], rowCount: 1 }); + } + return Promise.resolve({ rows: [{ id: 'message-uuid-1' }], rowCount: 1 }); + }); + + const result = await parseAndStoreMessage({ reportId: REPORT_ID, ticketId: TICKET_ID }); + + expect(result).toEqual({ stored: false, reason: 'already-parsed' }); + expect(getAttachmentsMock).not.toHaveBeenCalled(); + expect(getAttachmentContentMock).not.toHaveBeenCalled(); + expect(callsContaining('INSERT INTO messages')).toHaveLength(0); + }); }); diff --git a/lib/services/phishing-eml-service.ts b/lib/services/phishing-eml-service.ts index 7763f92..aef9a76 100644 --- a/lib/services/phishing-eml-service.ts +++ b/lib/services/phishing-eml-service.ts @@ -52,6 +52,20 @@ export async function parseAndStoreMessage( const { reportId, ticketId } = input; try { + // Idempotency guard (Defect 3, quick task 260717-a19): a ticket.update + // webhook may retry parsing for a report that already has a messages + // row (e.g. the CREATE-time attempt succeeded after all, or a prior + // UPDATE retry already parsed it). Short-circuit before any Autotask + // attachment fetch so a repeated call is a cheap no-op, never a + // duplicate messages row. + const existing = await postgresClient.query<{ id: string }>( + `SELECT id FROM messages WHERE report_id = $1 LIMIT 1`, + [reportId] + ); + if (existing.rows.length > 0) { + return { stored: false, reason: 'already-parsed' }; + } + // reports.evidence only stores fullPath/title/contentType (no attachment // id) — list live so we have the attachment id needed for the // per-attachment content fetch below. diff --git a/lib/services/webhook-service.ts b/lib/services/webhook-service.ts index c7bc9b8..1ada941 100644 --- a/lib/services/webhook-service.ts +++ b/lib/services/webhook-service.ts @@ -125,6 +125,19 @@ export class WebhookService { ); } + // Defect 2 (quick task 260717-a19): the CREATE-time .eml parse attempt + // can race the Autotask attachment being available, permanently + // starving the classifier of evidence with no retry. Reuse the + // ticket.update traffic every flagged ticket already receives to + // retry the missing-EML parse only — never re-run detect/group/ + // classify/report here. Safe to fire on every update because + // parseAndStoreMessage is now idempotent (Defect 3). + if (payload.entityType === WebhookEntityType.TICKETS && payload.eventType === WebhookEventType.UPDATE) { + this.retryPhishingParseOnUpdate(payload).catch(err => + console.error('[WEBHOOK] Phishing retry-parse error:', err) + ); + } + return { success: true, @@ -506,6 +519,55 @@ export class WebhookService { } } + /** + * Defect 2 fix (quick task 260717-a19): retries the missing-EML parse for + * an already-flagged phishing report on ticket.update webhook traffic. + * + * Autotask's attachment-available timing can lag the ticket.created + * webhook by more than the CREATE-time parse attempt allows for, leaving + * a flagged report permanently without a `messages` row (no retry existed + * before this fix). Rather than add new polling/cron, this reuses the + * ticket.update events a flagged ticket already receives (5+ observed on + * Seubert ticket 699456) to attempt the parse again — bounded to exactly + * one `parseAndStoreMessage` call per update, gated on auto_parse, and + * only when there is still no messages row (parseAndStoreMessage's own + * Defect 3 guard makes repeat calls safe regardless). + * + * Deliberately narrow: no detection, grouping, classify, or report here — + * only the missing-EML retry-parse. + */ + private async retryPhishingParseOnUpdate(payload: AutotaskWebhookPayload): Promise { + const reportRow = await postgresClient.query<{ id: string; company_id: number | null }>( + `SELECT id::text AS id, company_id FROM reports WHERE ticket_id = $1`, + [payload.entityId] + ); + const report = reportRow.rows[0]; + if (!report) { + // Not a flagged phishing ticket — nothing to retry. + return; + } + + const messageRow = await postgresClient.query<{ id: string }>( + `SELECT id FROM messages WHERE report_id = $1 LIMIT 1`, + [report.id] + ); + if (messageRow.rows.length > 0) { + // Already parsed — nothing to retry. + return; + } + + const gate = await getCompanyAutomationGate(report.company_id); + if (!gate.autoParse) { + return; + } + + try { + await parseAndStoreMessage({ reportId: report.id, ticketId: Number(payload.entityId) }); + } catch (err) { + console.error('[WEBHOOK] retryPhishingParseOnUpdate parse error', err); + } + } + /** * Phase 23 D-04/D-06/D-07: runs the opted-in parse -> classify -> report * chain for a company after detection + grouping have already run From 78bb30aabc4309f1777df57f6455829c916c1f50 Mon Sep 17 00:00:00 2001 From: lorentz Date: Fri, 17 Jul 2026 07:21:43 -0400 Subject: [PATCH 3/3] chore(quick-260717-a19): add one-off reclassify script for 6 Seubert campaigns - Looks up campaign_id live per ticket (699419/699421/699422/699433/ 699435/699456), no hardcoded UUIDs - Reads before verdict, calls classifyCampaign (D-02 append-only), prints before -> after verdict + confidence per ticket - Dedupes shared campaign_ids so a shared campaign isn't classified twice - Depends on Task 1's allowlist fix already being committed --- scripts/reclassify-seubert-campaigns.ts | 139 ++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 scripts/reclassify-seubert-campaigns.ts diff --git a/scripts/reclassify-seubert-campaigns.ts b/scripts/reclassify-seubert-campaigns.ts new file mode 100644 index 0000000..66e5ac9 --- /dev/null +++ b/scripts/reclassify-seubert-campaigns.ts @@ -0,0 +1,139 @@ +/** + * reclassify-seubert-campaigns.ts + * + * One-off ops script (quick task 260717-a19): reclassifies the 6 Seubert + * phishing tickets that were misrouted to UNWANTED before the + * KNOWN_SIMULATION_SENDERS allowlist gained the 3 confirmed KnowBe4 domains + * (customer-portal.info, cloud-service-care.com, bankonlinesupport.com) in + * this same quick task. MUST run after that allowlist commit lands — it + * depends on the corrected allowlist data path in classifyCampaign(). + * + * For each of the 6 tickets: + * 1. Looks up campaign_id LIVE via `reports.ticket_id` (never hardcoded). + * 2. Reads the current ("before") verdict from `classifications`. + * 3. Calls classifyCampaign(campaignId) — appends a new classifications + * row (D-02 append-only; the newest row becomes "current"). Never + * overwrites history. + * 4. Prints ticketId, campaignId, before verdict -> after verdict, + * confidence. + * 5. Prints a summary count of how many flipped to USER_AWARENESS. + * + * Two pairs of tickets may share a single campaign_id + * (699421+699422, 699419+699433) — campaign_ids are deduped before calling + * classifyCampaign so a shared campaign is never classified twice, while + * still reporting the full per-ticket mapping. + * + * Usage: + * POSTGRES_HOST=localhost npx tsx scripts/reclassify-seubert-campaigns.ts + */ + +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../.env.local') }); +config({ path: resolve(__dirname, '../.env') }); + +// When run from the host (not inside the docker network), POSTGRES_HOST is +// 'postgres' which won't resolve. Fall back to localhost. +if (process.env.POSTGRES_HOST === 'postgres') { + process.env.POSTGRES_HOST = 'localhost'; +} + +import postgresClient from '../lib/services/postgres-client'; +import { classifyCampaign } from '../lib/services/campaign-classifier'; + +const TICKET_IDS = [699419, 699421, 699422, 699433, 699435, 699456]; + +interface TicketMapping { + ticketId: number; + campaignId: string | null; +} + +async function lookupCampaignId(ticketId: number): Promise { + const result = await postgresClient.query<{ campaign_id: string | null }>( + `SELECT campaign_id::text AS campaign_id FROM reports WHERE ticket_id = $1`, + [ticketId] + ); + return result.rows[0]?.campaign_id ?? null; +} + +async function lookupBeforeVerdict(campaignId: string): Promise { + const result = await postgresClient.query<{ verdict: string | null }>( + `SELECT verdict FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC LIMIT 1`, + [campaignId] + ); + return result.rows[0]?.verdict ?? null; +} + +async function main() { + console.log('[reclassify-seubert-campaigns] starting for tickets:', TICKET_IDS.join(', ')); + console.log(''); + + // Step 1: resolve campaign_id per ticket LIVE (no hardcoded UUIDs). + const mappings: TicketMapping[] = []; + for (const ticketId of TICKET_IDS) { + const campaignId = await lookupCampaignId(ticketId); + if (!campaignId) { + console.warn(`[warn] ticket ${ticketId} — no campaign_id found (reports.campaign_id is null); skipping`); + } + mappings.push({ ticketId, campaignId }); + } + + // Step 2: read "before" verdict per unique campaign (dedupe before + // classifying — shared campaigns must not be classified twice). + const uniqueCampaignIds = [...new Set(mappings.map((m) => m.campaignId).filter((id): id is string => !!id))]; + const beforeByCampaign = new Map(); + for (const campaignId of uniqueCampaignIds) { + beforeByCampaign.set(campaignId, await lookupBeforeVerdict(campaignId)); + } + + // Step 3: classify each unique campaign exactly once. + const afterByCampaign = new Map(); + for (const campaignId of uniqueCampaignIds) { + try { + const result = await classifyCampaign(campaignId); + afterByCampaign.set(campaignId, { verdict: result.verdict, confidence: result.confidence }); + } catch (err) { + console.error( + `[err] campaign ${campaignId} — classifyCampaign failed: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + // Step 4: print per-ticket before -> after lines. + console.log(''); + console.log('Per-ticket results:'); + let flippedToUserAwareness = 0; + for (const { ticketId, campaignId } of mappings) { + if (!campaignId) { + console.log(` ticket ${ticketId}: SKIPPED (no campaign_id)`); + continue; + } + const before = beforeByCampaign.get(campaignId) ?? '(none)'; + const after = afterByCampaign.get(campaignId); + if (!after) { + console.log(` ticket ${ticketId} (campaign ${campaignId}): SKIPPED (classifyCampaign failed)`); + continue; + } + console.log( + ` ticket ${ticketId} (campaign ${campaignId}): ${before} -> ${after.verdict} (confidence ${after.confidence})` + ); + if (after.verdict === 'USER_AWARENESS') { + flippedToUserAwareness++; + } + } + + // Step 5: summary. + console.log(''); + console.log( + `[reclassify-seubert-campaigns] done. ${uniqueCampaignIds.length} unique campaign(s) reclassified across ${TICKET_IDS.length} tickets; ${flippedToUserAwareness} ticket(s) now USER_AWARENESS.` + ); + + // pg pool keeps the process alive otherwise. + process.exit(0); +} + +main().catch((err) => { + console.error('[reclassify-seubert-campaigns] fatal:', err); + process.exit(1); +});