chore: merge quick task worktree (worktree-agent-a498794d9a221b8b7)
This commit is contained in:
commit
1cd0d4db48
2 changed files with 154 additions and 19 deletions
|
|
@ -247,12 +247,14 @@ interface ReportFixtureRow {
|
|||
title: string | null;
|
||||
created_at: string;
|
||||
requester_email: string | null;
|
||||
company_id: string | null;
|
||||
}
|
||||
|
||||
interface StagedRows {
|
||||
reports?: ReportFixtureRow[];
|
||||
messages?: Array<{ id: string; report_id: string; headers: NormalizedMessage }>;
|
||||
indicators?: Array<{ id: string; message_id: string; indicator_type: string; value: string }>;
|
||||
mimecastTenants?: Array<{ client_id: string; client_secret: string; base_url: string | null }>;
|
||||
}
|
||||
|
||||
function stageQueries(rows: StagedRows) {
|
||||
|
|
@ -260,6 +262,9 @@ function stageQueries(rows: StagedRows) {
|
|||
if (sql.includes('INSERT INTO classifications')) {
|
||||
return { rows: [{ id: 'classification-1', created_at: '2026-07-16T00:00:00.000Z' }], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('FROM mimecast_tenants')) {
|
||||
return { rows: rows.mimecastTenants ?? [], rowCount: rows.mimecastTenants?.length ?? 0 };
|
||||
}
|
||||
if (sql.includes('FROM reports')) {
|
||||
return { rows: rows.reports ?? [], rowCount: rows.reports?.length ?? 0 };
|
||||
}
|
||||
|
|
@ -288,7 +293,7 @@ describe('classifyCampaign', () => {
|
|||
it('returns exactly one verdict with the full payload shape (returns exactly one verdict)', async () => {
|
||||
stageQueries({
|
||||
reports: [
|
||||
{ id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL },
|
||||
{ id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL , company_id: null},
|
||||
],
|
||||
messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)],
|
||||
indicators: [],
|
||||
|
|
@ -320,7 +325,7 @@ describe('classifyCampaign', () => {
|
|||
it('inserts exactly one append-only classifications row with no ON CONFLICT', async () => {
|
||||
stageQueries({
|
||||
reports: [
|
||||
{ id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL },
|
||||
{ id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL , company_id: null},
|
||||
],
|
||||
messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)],
|
||||
indicators: [],
|
||||
|
|
@ -353,7 +358,7 @@ describe('classifyCampaign', () => {
|
|||
async (_label, fixture) => {
|
||||
stageQueries({
|
||||
reports: [
|
||||
{ id: 'report-1', title: fixture.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL },
|
||||
{ id: 'report-1', title: fixture.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL , company_id: null},
|
||||
],
|
||||
messages: [toMessageRow('message-1', 'report-1', fixture)],
|
||||
indicators: [],
|
||||
|
|
@ -383,7 +388,7 @@ describe('classifyCampaign', () => {
|
|||
async (_label, fixture) => {
|
||||
stageQueries({
|
||||
reports: [
|
||||
{ id: 'report-1', title: fixture.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL },
|
||||
{ id: 'report-1', title: fixture.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL , company_id: null},
|
||||
],
|
||||
messages: [toMessageRow('message-1', 'report-1', fixture)],
|
||||
indicators: [],
|
||||
|
|
@ -410,7 +415,7 @@ describe('classifyCampaign', () => {
|
|||
it('classifies a real non-simulation signal as THREAT with destructive recommended actions (threat tier)', async () => {
|
||||
stageQueries({
|
||||
reports: [
|
||||
{ id: 'report-1', title: threatMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL },
|
||||
{ id: 'report-1', title: threatMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL , company_id: null},
|
||||
],
|
||||
messages: [toMessageRow('message-1', 'report-1', threatMessage)],
|
||||
indicators: [],
|
||||
|
|
@ -442,8 +447,8 @@ describe('classifyCampaign', () => {
|
|||
const sharedUrl = 'http://evil-shared.example.test/payload';
|
||||
stageQueries({
|
||||
reports: [
|
||||
{ id: 'report-1', title: 'Invoice attached', created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL },
|
||||
{ id: 'report-2', title: 'Invoice attached', created_at: '2026-07-15T02:00:00.000Z', requester_email: 'reporter2@wulfconsulting.test' },
|
||||
{ id: 'report-1', title: 'Invoice attached', created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL , company_id: null},
|
||||
{ id: 'report-2', title: 'Invoice attached', created_at: '2026-07-15T02:00:00.000Z', requester_email: 'reporter2@wulfconsulting.test' , company_id: null},
|
||||
],
|
||||
messages: [
|
||||
toMessageRow('message-1', 'report-1', cleanSpamMessage),
|
||||
|
|
@ -473,7 +478,7 @@ describe('classifyCampaign', () => {
|
|||
it('classifies a clean campaign with no indicators and no delivery/click signal as SPAM (spam vs unwanted tier)', async () => {
|
||||
stageQueries({
|
||||
reports: [
|
||||
{ id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL },
|
||||
{ id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL , company_id: null},
|
||||
],
|
||||
messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)],
|
||||
indicators: [],
|
||||
|
|
@ -497,7 +502,7 @@ describe('classifyCampaign', () => {
|
|||
it('classifies a suspicious-but-contained campaign (one url indicator, delivery contained to reporter) as UNWANTED (spam vs unwanted tier)', async () => {
|
||||
stageQueries({
|
||||
reports: [
|
||||
{ id: 'report-1', title: suspiciousUnwantedMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL },
|
||||
{ id: 'report-1', title: suspiciousUnwantedMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL , company_id: null},
|
||||
],
|
||||
messages: [toMessageRow('message-1', 'report-1', suspiciousUnwantedMessage)],
|
||||
indicators: [
|
||||
|
|
@ -529,7 +534,7 @@ describe('classifyCampaign', () => {
|
|||
}));
|
||||
stageQueries({
|
||||
reports: [
|
||||
{ id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL },
|
||||
{ id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL , company_id: null},
|
||||
],
|
||||
messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)],
|
||||
indicators: manyIndicators,
|
||||
|
|
@ -552,4 +557,91 @@ describe('classifyCampaign', () => {
|
|||
expect(reason.length).toBeLessThan(300);
|
||||
}
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Bug 2 (D-05) parity: per-company Mimecast tenant resolution vs. global
|
||||
// env fallback — mirrors app/api/phishing/campaigns/[id]/route.ts's
|
||||
// already-tested tenant-resolution block (260721-n49).
|
||||
// ===========================================================================
|
||||
|
||||
it('resolves the reporting company\'s own Mimecast tenant and scopes getBlastRadius when an enabled mimecast_tenants row exists (tenant resolution)', async () => {
|
||||
stageQueries({
|
||||
reports: [
|
||||
{
|
||||
id: 'report-1',
|
||||
title: cleanSpamMessage.subject,
|
||||
created_at: '2026-07-15T00:00:00.000Z',
|
||||
requester_email: REPORTER_EMAIL,
|
||||
company_id: '29683407',
|
||||
},
|
||||
],
|
||||
messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)],
|
||||
indicators: [],
|
||||
mimecastTenants: [
|
||||
{ client_id: 'tenant-client-id', client_secret: 'tenant-client-secret', base_url: 'https://eu-api.mimecast.com' },
|
||||
],
|
||||
});
|
||||
getBlastRadiusMock.mockResolvedValue({
|
||||
status: 'ok',
|
||||
matched: 1,
|
||||
delivered: 0,
|
||||
held: 1,
|
||||
rejected: 0,
|
||||
clicked: 0,
|
||||
perRecipient: [],
|
||||
source: 'fan-out',
|
||||
});
|
||||
|
||||
await classifyCampaign('campaign-1');
|
||||
|
||||
expect(getBlastRadiusMock).toHaveBeenCalledTimes(1);
|
||||
const [, tenantOptions] = getBlastRadiusMock.mock.calls[0];
|
||||
expect(tenantOptions).toBeDefined();
|
||||
expect(tenantOptions.cacheScope).toBe('29683407');
|
||||
expect(tenantOptions.client).toBeTruthy();
|
||||
|
||||
const tenantQueryCalls = queryMock.mock.calls.filter(
|
||||
([sql]) => typeof sql === 'string' && sql.includes('FROM mimecast_tenants')
|
||||
);
|
||||
expect(tenantQueryCalls).toHaveLength(1);
|
||||
expect(tenantQueryCalls[0][0]).toMatch(/company_id = \$1 AND enabled = true/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no companyId on the primary report', null, [] as Array<{ client_id: string; client_secret: string; base_url: string | null }>],
|
||||
['companyId set but no enabled tenant row', '29683407', []],
|
||||
])(
|
||||
'calls getBlastRadius with no tenant scoping when %s (global fallback preserved)',
|
||||
async (_label, companyId, mimecastTenants) => {
|
||||
stageQueries({
|
||||
reports: [
|
||||
{
|
||||
id: 'report-1',
|
||||
title: cleanSpamMessage.subject,
|
||||
created_at: '2026-07-15T00:00:00.000Z',
|
||||
requester_email: REPORTER_EMAIL,
|
||||
company_id: companyId,
|
||||
},
|
||||
],
|
||||
messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)],
|
||||
indicators: [],
|
||||
mimecastTenants,
|
||||
});
|
||||
getBlastRadiusMock.mockResolvedValue({
|
||||
status: 'ok',
|
||||
matched: 1,
|
||||
delivered: 0,
|
||||
held: 1,
|
||||
rejected: 0,
|
||||
clicked: 0,
|
||||
perRecipient: [],
|
||||
source: 'fan-out',
|
||||
});
|
||||
|
||||
await classifyCampaign('campaign-1');
|
||||
|
||||
expect(getBlastRadiusMock).toHaveBeenCalledTimes(1);
|
||||
expect(getBlastRadiusMock.mock.calls[0][1]).toBeUndefined();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
import type { AuthResults } from './eml-parser';
|
||||
import { postgresClient } from './postgres-client';
|
||||
import { getBlastRadius, type BlastRadiusResult } from './mimecast-blast-radius';
|
||||
import { getMimecastClientForTenant } from './mimecast-client';
|
||||
|
||||
// =============================================================================
|
||||
// D-06/D-07: KnowBe4 / Breach Secure Now simulation sender-domain allowlist
|
||||
|
|
@ -213,6 +214,14 @@ interface ReportDbRow {
|
|||
title: string | null;
|
||||
created_at: string;
|
||||
requester_email: string | null;
|
||||
company_id: string | null;
|
||||
}
|
||||
|
||||
/** Mirrors app/api/phishing/campaigns/[id]/route.ts's MimecastTenantRow shape verbatim. */
|
||||
interface MimecastTenantRow {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
base_url: string | null;
|
||||
}
|
||||
|
||||
interface MessageDbRow {
|
||||
|
|
@ -256,6 +265,7 @@ export interface CampaignReportSummary {
|
|||
title: string | null;
|
||||
createdAt: string;
|
||||
requesterEmail: string | null;
|
||||
companyId: string | null;
|
||||
}
|
||||
|
||||
export interface CampaignEvidence {
|
||||
|
|
@ -283,7 +293,7 @@ export async function gatherCampaignEvidence(campaignId: string): Promise<Campai
|
|||
// app/api/phishing/campaigns/[id]/route.ts's existing bulk-fetch shape).
|
||||
const reportsRes = await postgresClient.query<ReportDbRow>(
|
||||
`SELECT r.id::text AS id, r.title, r.created_at::text AS created_at,
|
||||
c.email_address AS requester_email
|
||||
c.email_address AS requester_email, r.company_id::text AS company_id
|
||||
FROM reports r
|
||||
LEFT JOIN contacts c ON c.id = r.requester_contact_id
|
||||
WHERE r.campaign_id = $1
|
||||
|
|
@ -295,6 +305,7 @@ export async function gatherCampaignEvidence(campaignId: string): Promise<Campai
|
|||
title: r.title,
|
||||
createdAt: r.created_at,
|
||||
requesterEmail: r.requester_email,
|
||||
companyId: r.company_id,
|
||||
}));
|
||||
const reportIds = reports.map((r) => r.id);
|
||||
|
||||
|
|
@ -347,15 +358,47 @@ export async function gatherCampaignEvidence(campaignId: string): Promise<Campai
|
|||
(i) => i.messageId === primaryMessage?.id && i.indicatorType === 'sender'
|
||||
);
|
||||
const createdAt = new Date(primaryReport.createdAt);
|
||||
blastRadius = await getBlastRadius({
|
||||
sender: senderIndicator?.value ?? primaryMessage?.from.email ?? '',
|
||||
recipient: primaryReport.requesterEmail ?? '',
|
||||
subject: primaryMessage?.subject ?? primaryReport.title ?? '',
|
||||
dateWindow: {
|
||||
start: new Date(createdAt.getTime() - 24 * 60 * 60 * 1000),
|
||||
end: new Date(createdAt.getTime() + 24 * 60 * 60 * 1000),
|
||||
|
||||
// Bug 2 (D-05): resolve the reporting company's own registered Mimecast
|
||||
// tenant, if one exists, and query it directly instead of the global
|
||||
// env-configured (Wulf) tenant. Falls back to the global client when the
|
||||
// company has no enabled mimecast_tenants row. Mirrors
|
||||
// app/api/phishing/campaigns/[id]/route.ts's identical tenant-resolution
|
||||
// block verbatim — same query, same client build, same options shape.
|
||||
let tenantOptions: { client: ReturnType<typeof getMimecastClientForTenant>; cacheScope: string } | undefined;
|
||||
if (primaryReport.companyId) {
|
||||
const tenantRes = await postgresClient.query<MimecastTenantRow>(
|
||||
`SELECT client_id, client_secret, base_url
|
||||
FROM mimecast_tenants
|
||||
WHERE company_id = $1 AND enabled = true
|
||||
ORDER BY id LIMIT 1`,
|
||||
[primaryReport.companyId]
|
||||
);
|
||||
const tenantRow = tenantRes.rows[0];
|
||||
if (tenantRow) {
|
||||
tenantOptions = {
|
||||
client: getMimecastClientForTenant({
|
||||
client_id: tenantRow.client_id,
|
||||
client_secret: tenantRow.client_secret,
|
||||
base_url: tenantRow.base_url ?? undefined,
|
||||
}),
|
||||
cacheScope: primaryReport.companyId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
blastRadius = await getBlastRadius(
|
||||
{
|
||||
sender: senderIndicator?.value ?? primaryMessage?.from.email ?? '',
|
||||
recipient: primaryReport.requesterEmail ?? '',
|
||||
subject: primaryMessage?.subject ?? primaryReport.title ?? '',
|
||||
dateWindow: {
|
||||
start: new Date(createdAt.getTime() - 24 * 60 * 60 * 1000),
|
||||
end: new Date(createdAt.getTime() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
},
|
||||
});
|
||||
tenantOptions
|
||||
);
|
||||
} else {
|
||||
// No report ever linked to this campaign — nothing to look up (research A6).
|
||||
blastRadius = { status: 'unavailable', reason: 'not_configured' };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue