chore: merge quick task worktree (worktree-agent-ac1a694d510841c38)
This commit is contained in:
commit
c2a64fab9a
6 changed files with 266 additions and 4 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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
|
||||
|
|
|
|||
139
scripts/reclassify-seubert-campaigns.ts
Normal file
139
scripts/reclassify-seubert-campaigns.ts
Normal file
|
|
@ -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<string | null> {
|
||||
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<string | null> {
|
||||
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<string, string | null>();
|
||||
for (const campaignId of uniqueCampaignIds) {
|
||||
beforeByCampaign.set(campaignId, await lookupBeforeVerdict(campaignId));
|
||||
}
|
||||
|
||||
// Step 3: classify each unique campaign exactly once.
|
||||
const afterByCampaign = new Map<string, { verdict: string; confidence: number }>();
|
||||
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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue