diff --git a/.planning/phases/17-mimecast-blast-radius-lookup/deferred-items.md b/.planning/phases/17-mimecast-blast-radius-lookup/deferred-items.md new file mode 100644 index 0000000..f157c18 --- /dev/null +++ b/.planning/phases/17-mimecast-blast-radius-lookup/deferred-items.md @@ -0,0 +1,21 @@ +# Deferred Items — Phase 17, Plan 01 + +Out-of-scope discoveries found during execution. Not fixed per SCOPE BOUNDARY +(only auto-fix issues directly caused by the current task's changes). + +## Pre-existing test failures unrelated to this plan + +`npm test` (full suite) shows 2 pre-existing failures in +`lib/services/analyzer/itglue-search.test.ts`: + +- `itglueSearch > returns capped, redacted doc snippets when the org is found` +- `itglueSearch > tolerates per-call failures (configurations errors, flex still returns)` + +Both fail with a `client.getFlexibleAssetsForOrganization is not a function` +stderr log and count-mismatch assertions. Neither `lib/services/analyzer/ +itglue-search.ts` nor its test file was touched by Phase 17 Plan 01 (which only +modified `lib/services/mimecast-client.ts` and added +`lib/services/mimecast-blast-radius.ts` + their test files). Last commit +touching those files: `a0a6e7f fix(itglue): list flexible assets per type to +satisfy API 422 requirement` — predates this plan's work. Left unfixed; not +in scope for Phase 17. diff --git a/lib/services/mimecast-blast-radius.test.ts b/lib/services/mimecast-blast-radius.test.ts new file mode 100644 index 0000000..0945ad1 --- /dev/null +++ b/lib/services/mimecast-blast-radius.test.ts @@ -0,0 +1,227 @@ +/** + * mimecast-blast-radius.ts — getBlastRadius() orchestration tests. + * + * Mocks './mimecast-client' and './redis-client' entirely (following + * phishing-eml-service.test.ts's vi.mock() factory-mocking discipline) — no + * real Mimecast network calls or Redis connection are used. Synthetic inline + * fixtures matching MimecastDeliveredMessage/MimecastHeldMessage/ + * MimecastThreatEvent shapes are used directly; no fixture file needed. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const isMimecastConfiguredMock = vi.fn(); +const getMessageInfoMock = vi.fn(); +const searchDeliveredMessagesMock = vi.fn(); +const getHeldMessagesMock = vi.fn(); +const getThreatEventsMock = vi.fn(); +const getMimecastClientMock = vi.fn(() => ({ + getMessageInfo: getMessageInfoMock, + searchDeliveredMessages: searchDeliveredMessagesMock, + getHeldMessages: getHeldMessagesMock, + getThreatEvents: getThreatEventsMock, +})); + +vi.mock('./mimecast-client', () => ({ + isMimecastConfigured: () => isMimecastConfiguredMock(), + getMimecastClient: () => getMimecastClientMock(), +})); + +const getCachedDataMock = vi.fn(); +const setCachedDataMock = vi.fn(); +vi.mock('./redis-client', () => ({ + getCachedData: (...args: unknown[]) => getCachedDataMock(...args), + setCachedData: (...args: unknown[]) => setCachedDataMock(...args), +})); + +// Import AFTER the mocks are declared so vi.mock hoisting takes effect. +import { getBlastRadius, type BlastRadiusInput } from './mimecast-blast-radius'; + +const BASE_INPUT: BlastRadiusInput = { + sender: 'attacker@evil.test', + recipient: 'reporter@wulfconsulting.test', + subject: 'Urgent: verify your account', + dateWindow: { + start: new Date('2026-07-14T00:00:00.000Z'), + end: new Date('2026-07-15T00:00:00.000Z'), + }, +}; + +describe('getBlastRadius', () => { + beforeEach(() => { + isMimecastConfiguredMock.mockReset().mockReturnValue(true); + getMessageInfoMock.mockReset().mockResolvedValue(null); + searchDeliveredMessagesMock.mockReset().mockResolvedValue({ messages: [] }); + getHeldMessagesMock.mockReset().mockResolvedValue({ messages: [], totalCount: 0 }); + getThreatEventsMock.mockReset().mockResolvedValue({ items: [], nextCursor: null }); + getCachedDataMock.mockReset().mockResolvedValue(null); + setCachedDataMock.mockReset().mockResolvedValue(undefined); + }); + + it('returns unavailable/not_configured synchronously and never calls getMimecastClient when unconfigured', async () => { + isMimecastConfiguredMock.mockReturnValue(false); + + const result = await getBlastRadius(BASE_INPUT); + + expect(result).toEqual({ status: 'unavailable', reason: 'not_configured' }); + expect(getMimecastClientMock).not.toHaveBeenCalled(); + expect(searchDeliveredMessagesMock).not.toHaveBeenCalled(); + expect(getHeldMessagesMock).not.toHaveBeenCalled(); + expect(getThreatEventsMock).not.toHaveBeenCalled(); + }); + + it('returns the cached result on a cache hit without calling any MimecastClient method', async () => { + const cachedResult = { + status: 'ok' as const, + matched: 1, + delivered: 1, + held: 0, + rejected: 0, + clicked: 0, + perRecipient: [{ recipient: BASE_INPUT.recipient, status: 'delivered' as const }], + source: 'fan-out' as const, + }; + getCachedDataMock.mockResolvedValue(cachedResult); + + const result = await getBlastRadius(BASE_INPUT); + + expect(result).toEqual(cachedResult); + expect(searchDeliveredMessagesMock).not.toHaveBeenCalled(); + expect(getHeldMessagesMock).not.toHaveBeenCalled(); + expect(getThreatEventsMock).not.toHaveBeenCalled(); + expect(setCachedDataMock).not.toHaveBeenCalled(); + }); + + it('merges delivered/held/threat-event fixtures into a normalized ok result and caches it once', async () => { + searchDeliveredMessagesMock.mockResolvedValue({ + messages: [ + { + id: 'd1', + status: 'Delivered', + subject: BASE_INPUT.subject, + from: BASE_INPUT.sender, + fromEnv: BASE_INPUT.sender, + to: BASE_INPUT.recipient, + toDisplay: 'Reporter', + received: '2026-07-14T12:00:00+0000', + senderIP: '1.2.3.4', + spamScore: 0, + detectionLevel: 'none', + attachments: false, + route: 'inbound', + info: '', + }, + { + id: 'd2', + status: 'Rejected', + subject: BASE_INPUT.subject, + from: BASE_INPUT.sender, + fromEnv: BASE_INPUT.sender, + to: 'other-recipient@wulfconsulting.test', + toDisplay: 'Other', + received: '2026-07-14T12:05:00+0000', + senderIP: '1.2.3.4', + spamScore: 0, + detectionLevel: 'none', + attachments: false, + route: 'inbound', + info: '', + }, + ], + }); + getHeldMessagesMock.mockResolvedValue({ + messages: [ + { + id: 'h1', + subject: BASE_INPUT.subject, + from: BASE_INPUT.sender, + fromDisplay: 'Attacker', + to: 'held-recipient@wulfconsulting.test', + toDisplay: 'Held', + dateReceived: '2026-07-14T13:00:00+0000', + reason: 'spam', + reasonCode: 'SPAM', + policyInfo: '', + route: 'inbound', + hasAttachments: false, + size: 100, + }, + ], + totalCount: 1, + }); + getThreatEventsMock.mockResolvedValue({ + items: [ + { + id: 't1', + eventType: 'click', + analysis: ['click'], + source: [], + direction: [], + status: [], + }, + ], + nextCursor: null, + }); + + const result = await getBlastRadius(BASE_INPUT); + + expect(result).toEqual({ + status: 'ok', + matched: 2, // 1 non-rejected delivered + 1 held + delivered: 1, + held: 1, + rejected: 1, + clicked: 1, + perRecipient: expect.arrayContaining([ + { recipient: BASE_INPUT.recipient, status: 'delivered' }, + { recipient: 'other-recipient@wulfconsulting.test', status: 'rejected' }, + { recipient: 'held-recipient@wulfconsulting.test', status: 'held' }, + ]), + source: 'fan-out', + }); + expect(setCachedDataMock).toHaveBeenCalledTimes(1); + expect(setCachedDataMock).toHaveBeenCalledWith(expect.any(String), result, 300); + }); + + it('clicked is 0 when no click-type analysis value is present', async () => { + getThreatEventsMock.mockResolvedValue({ + items: [{ id: 't1', eventType: 'malware', analysis: ['malware'], source: [], direction: [], status: [] }], + nextCursor: null, + }); + + const result = await getBlastRadius(BASE_INPUT); + + expect(result.status).toBe('ok'); + if (result.status === 'ok') { + expect(result.clicked).toBe(0); + } + }); + + it('degrades to unavailable/lookup_failed (never throws) when a fan-out call rejects', async () => { + getHeldMessagesMock.mockRejectedValue(new Error('Mimecast API timeout')); + + const result = await getBlastRadius(BASE_INPUT); + + expect(result).toEqual({ + status: 'unavailable', + reason: 'lookup_failed', + error: 'Mimecast API timeout', + }); + expect(setCachedDataMock).not.toHaveBeenCalled(); + }); + + it('a recipient with no delivered/held rows at all is reported as unknown', async () => { + const result = await getBlastRadius(BASE_INPUT); + + expect(result).toEqual({ + status: 'ok', + matched: 0, + delivered: 0, + held: 0, + rejected: 0, + clicked: 0, + perRecipient: [{ recipient: BASE_INPUT.recipient, status: 'unknown' }], + source: 'fan-out', + }); + }); +}); diff --git a/lib/services/mimecast-blast-radius.ts b/lib/services/mimecast-blast-radius.ts new file mode 100644 index 0000000..7be67e5 --- /dev/null +++ b/lib/services/mimecast-blast-radius.ts @@ -0,0 +1,195 @@ +/** + * Mimecast blast-radius lookup abstraction (Phase 17). + * + * Given a reported message's identity (Message-ID, sender, recipient, + * subject, date window), returns normalized delivery data — matched/ + * delivered/held/rejected/clicked counts and per-recipient status — when + * Mimecast is configured, or a clean `status: 'unavailable'` signal (never a + * throw) when it isn't or when the underlying lookup fails unexpectedly. + * + * Three load-bearing facts: + * + * (a) EPHEMERAL (D-03): this lookup is a pure function call — nothing is + * persisted here. If Phase 19's classifier wants to keep a result, it + * writes to its own `classifications.reasons` JSONB column; that is + * Phase 19's concern, not this module's. + * + * (b) `clicked` IS BEST-EFFORT (D-02): it is derived from getThreatEvents()'s + * analysis[] subtype containing a click-like value. Mimecast's real + * click-tracking data (TTP URL Protect logs) lives in a separate, + * currently-unwrapped `/api/ttp/url/get-logs` endpoint (see + * 17-RESEARCH.md Pitfall 2) — this module does not call it. A value of + * `clicked: 0` means "no click-type threat event found in the events + * this module can see," NOT "confirmed zero clicks." + * + * (c) KNOWN LIMITATION — MULTI-TENANT GAP (D-05): this module uses only the + * single global env-var-configured getMimecastClient(), NOT the + * per-company `mimecast_tenants` table / getMimecastClientForTenant(). + * Reports belonging to companies with their own registered Mimecast + * tenant (not covered by the global MIMECAST_CLIENT_ID) will return + * `status: 'unavailable'` even though Mimecast is technically configured + * for that company. Per-tenant resolution is a deliberate, documented v1 + * gap — not a silent oversight — and would be a small, mechanical + * follow-up later (swap getMimecastClient() for a tenant lookup + + * getMimecastClientForTenant(), same fan-out/merge logic below). + */ + +import { + isMimecastConfigured, + getMimecastClient, + type MimecastDeliveredMessage, + type MimecastHeldMessage, +} from './mimecast-client'; +import { getCachedData, setCachedData } from './redis-client'; + +export interface BlastRadiusInput { + /** Optional Mimecast Message-ID — used only for supplementary body/header + * evidence via getMessageInfo(); it never gates or replaces the fan-out. */ + messageId?: string; + /** Reported message's sender address. Required — see T-17-01: never allow + * a date-range-only fan-out query. */ + sender: string; + /** Reporter/reported-to recipient address. Required — see T-17-01. */ + recipient: string; + /** Reported message's subject. Required — see T-17-01. */ + subject: string; + /** Date window to search within. Required — see T-17-01. */ + dateWindow: { start: Date; end: Date }; +} + +export type BlastRadiusResult = + | { status: 'unavailable'; reason: 'not_configured' | 'lookup_failed'; error?: string } + | { + status: 'ok'; + matched: number; + delivered: number; + held: number; + rejected: number; + clicked: number; + perRecipient: Array<{ recipient: string; status: 'delivered' | 'held' | 'rejected' | 'unknown' }>; + source: 'fan-out'; + }; + +/** + * Best-effort delivered/rejected split. The exact string values Mimecast + * returns in MimecastDeliveredMessage.status have NOT been confirmed against + * a live/sandbox tenant (17-RESEARCH.md A3 / Open Question #2) — only the + * TypeScript field name and type (`string`) are confirmed from the client + * code. Treat unrecognized status strings conservatively as NOT rejected + * (i.e. delivered) rather than hardcoding a guessed enum as if it were + * confirmed. Log raw values at debug level so a first real-tenant run can + * validate/refine this heuristic. + */ +function isRejectedStatus(status: string): boolean { + return /reject/i.test(status); +} + +/** Best-effort click-type detection (D-02) — see module doc-comment (b). */ +function isClickEvent(analysis: string[] | undefined): boolean { + return (analysis ?? []).some((a) => /click/i.test(a)); +} + +export async function getBlastRadius(input: BlastRadiusInput): Promise { + if (!isMimecastConfigured()) { + return { status: 'unavailable', reason: 'not_configured' }; + } + + const cacheKey = input.messageId + ? `mimecast:blast-radius:msgid:${input.messageId}` + : `mimecast:blast-radius:composite:${input.sender}:${input.subject}:${input.dateWindow.start.toISOString()}:${input.dateWindow.end.toISOString()}`; + + const cached = await getCachedData(cacheKey); + if (cached) return cached; + + try { + const client = getMimecastClient(); + + // Supplementary only — body/header evidence, never gates the fan-out + // (17-RESEARCH.md Pitfall 1: getMessageInfo() has no status/counts). + if (input.messageId) { + await client.getMessageInfo(input.messageId); + } + + const startStr = input.dateWindow.start.toISOString().replace(/\.\d{3}Z$/, '+0000'); + const endStr = input.dateWindow.end.toISOString().replace(/\.\d{3}Z$/, '+0000'); + + // D-01 (corrected): the fan-out runs unconditionally — it is the only + // source of the matched/delivered/held/rejected/clicked counts. + const [deliveredResult, heldResult, threatsResult] = await Promise.all([ + client.searchDeliveredMessages({ + to: input.recipient, + from: input.sender, + subject: input.subject, + start: startStr, + end: endStr, + }), + client.getHeldMessages({ recipient: input.recipient }), + client.getThreatEvents(), + ]); + + const deliveredRows: MimecastDeliveredMessage[] = deliveredResult.messages ?? []; + const heldRows: MimecastHeldMessage[] = heldResult.messages ?? []; + + const rawStatuses = Array.from(new Set(deliveredRows.map((m) => m.status))); + if (rawStatuses.length > 0) { + console.debug('[MIMECAST-BLAST-RADIUS] raw delivered-message status values seen:', rawStatuses); + } + + const rejectedRows = deliveredRows.filter((m) => isRejectedStatus(m.status)); + const nonRejectedDeliveredRows = deliveredRows.filter((m) => !isRejectedStatus(m.status)); + + const delivered = nonRejectedDeliveredRows.length; + const rejected = rejectedRows.length; + const held = heldRows.length; + // matched = delivered + held; a rejected recipient was never actually + // delivered, so rejected is NOT included in matched. + const matched = delivered + held; + + const clicked = (threatsResult.items ?? []).filter((t) => isClickEvent(t.analysis)).length; + + // Per-recipient merge: group by the single-string `to` field. Delivered + // (non-rejected) rows → 'delivered', rejected rows → 'rejected', held + // rows → 'held' (held overwrites a same-recipient delivered entry — a + // held message for a recipient is the more actionable signal). Ensure + // the originally-queried recipient is always represented, even if + // neither result set returned a row for them ('unknown'). + const perRecipientMap = new Map(); + for (const row of nonRejectedDeliveredRows) { + if (row.to) perRecipientMap.set(row.to, 'delivered'); + } + for (const row of rejectedRows) { + if (row.to) perRecipientMap.set(row.to, 'rejected'); + } + for (const row of heldRows) { + if (row.to) perRecipientMap.set(row.to, 'held'); + } + if (!perRecipientMap.has(input.recipient)) { + perRecipientMap.set(input.recipient, 'unknown'); + } + + const perRecipient = Array.from(perRecipientMap.entries()).map(([recipient, status]) => ({ + recipient, + status, + })); + + const result: BlastRadiusResult = { + status: 'ok', + matched, + delivered, + held, + rejected, + clicked, + perRecipient, + source: 'fan-out', + }; + + await setCachedData(cacheKey, result, 300); + return result; + } catch (err) { + // T-17-02: log err.message ONLY, never full Mimecast response bodies + // (which may contain other recipients' subjects/content). + const message = err instanceof Error ? err.message : String(err); + console.error('[MIMECAST-BLAST-RADIUS] lookup failed', message); + return { status: 'unavailable', reason: 'lookup_failed', error: message }; + } +}