wulf-pulse/lib/services/mimecast-blast-radius.ts
lorentz efbc437e2e feat(17-01): build mimecast-blast-radius.ts fan-out orchestration
- Add getBlastRadius(): never-throwing orchestration that fans out to
  searchDeliveredMessages + getHeldMessages + getThreatEvents (D-01,
  unconditional fan-out) and merges into normalized matched/delivered/
  held/rejected/clicked counts + perRecipient status array
- Config gate (BLAST-02): returns status:'unavailable' reason:'not_configured'
  synchronously when Mimecast is unconfigured, never constructs the client
- Redis-backed 5-min cache (D-04) via redis-client.ts, short-circuits before
  any MimecastClient call on hit
- clicked derived best-effort from getThreatEvents() analysis[] (D-02);
  documents the /api/ttp/url/get-logs limitation in code
- Documents D-05 known limitation: single global getMimecastClient() only,
  not per-company mimecast_tenants
- Unrecognized delivered-message status strings treated conservatively as
  non-rejected (A3 unconfirmed enum), raw values logged at debug level
- Add lib/services/mimecast-blast-radius.test.ts covering config gate,
  cache-hit short-circuit, fan-out merge, never-throw-on-error, and
  unknown-recipient classification
- Log pre-existing unrelated itglue-search.test.ts failures to
  deferred-items.md (out of scope for this plan)
2026-07-15 14:30:00 -04:00

195 lines
8.1 KiB
TypeScript

/**
* 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<BlastRadiusResult> {
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<BlastRadiusResult>(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<string, 'delivered' | 'held' | 'rejected' | 'unknown'>();
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 };
}
}