wulf-pulse/lib/services/mimecast-client.ts

560 lines
18 KiB
TypeScript
Raw Normal View History

/**
* Mimecast API 2.0 Client
* Auth: OAuth2 Client Credentials POST /oauth/token
* Base: https://api.services.mimecast.com
*
* Endpoints sourced from official Postman collection (mimecast-api-v2-collection.json):
* - Message logs: POST /api/message-finder/search (trackedEmails with pagination)
* - SIEM batch: GET /siem/v1/batch/events/cg (returns pre-signed URLs download JSON)
* - Threat events: GET /threats/v1/events
* - Message info: POST /api/message-finder/get-message-info
* - Account: POST /api/account/get-account
*/
export interface MimecastConfig {
clientId: string;
clientSecret: string;
baseUrl?: string;
accountCode?: string;
}
interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
}
interface TokenCache {
token: string;
expiresAt: number;
}
export interface MimecastMessage {
id: string;
senderAddress: string;
recipientAddress: string;
subject: string;
direction: string;
status: string;
action?: string;
spamScore?: number;
sizeBytes?: number;
attachmentCount?: boolean | number;
sentDateTime?: string;
receivedDateTime?: string;
route?: string;
rejectReason?: string;
heldReason?: string;
sourceIp?: string;
[key: string]: any;
}
export interface MimecastThreatEvent {
id: string;
messageId?: string;
eventType: string;
threatLevel?: string;
url?: string;
fileName?: string;
verdict?: string;
actorEmail?: string;
eventDateTime?: string;
analysis?: string[];
source?: string[];
direction?: string[];
status?: string[];
[key: string]: any;
}
export interface MimecastMessageInfo {
messageId: string;
bodyText?: string;
bodyHtml?: string;
headers?: Record<string, string>;
}
export interface MimecastHeldMessage {
id: string;
subject: string;
from: string;
fromDisplay: string;
to: string;
toDisplay: string;
dateReceived: string;
reason: string;
reasonCode: string;
policyInfo: string;
route: string;
hasAttachments: boolean;
size: number;
}
export interface MimecastCloudUser {
emailAddress: string;
domain: string;
lockedOut: boolean;
status?: string;
name?: string;
alias?: string[];
[key: string]: any;
}
export interface PaginatedResult<T> {
items: T[];
nextCursor: string | null;
totalCount?: number;
}
export class MimecastClient {
private readonly clientId: string;
private readonly clientSecret: string;
private readonly baseUrl: string;
private readonly accountCode: string;
private tokenCache: TokenCache | null = null;
constructor(config: MimecastConfig) {
this.clientId = config.clientId;
this.clientSecret = config.clientSecret;
this.baseUrl = (config.baseUrl ?? 'https://api.services.mimecast.com').replace(/\/$/, '');
this.accountCode = config.accountCode ?? '';
}
/**
* Format a Date to the Mimecast-required format: yyyy-MM-ddTHH:mm:ss+0000
* Note: NO milliseconds the API rejects .SSS variants
*/
static formatDate(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}` +
`T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}+0000`;
}
// ── Auth ─────────────────────────────────────────────────────────────────
private async getToken(): Promise<string> {
if (this.tokenCache && Date.now() < this.tokenCache.expiresAt - 60_000) {
return this.tokenCache.token;
}
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: this.clientId,
client_secret: this.clientSecret,
});
const res = await fetch(`${this.baseUrl}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Mimecast auth failed (${res.status}): ${text}`);
}
const data: TokenResponse = await res.json();
this.tokenCache = {
token: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
};
return data.access_token;
}
private async request<T>(
method: 'GET' | 'POST',
path: string,
body?: Record<string, any>,
params?: Record<string, string>
): Promise<T> {
const token = await this.getToken();
let url = `${this.baseUrl}${path}`;
if (params && Object.keys(params).length > 0) {
url = `${url}?${new URLSearchParams(params).toString()}`;
}
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (this.accountCode) {
headers['x-mc-account'] = this.accountCode;
}
const res = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Mimecast ${method} ${path} failed (${res.status}): ${text}`);
}
return res.json() as T;
}
// ── Message Tracking ─────────────────────────────────────────────────────
/**
* Search tracked emails via POST /api/message-finder/search
* Response: { data: [{ trackedEmails: [...], pageToken: string }] }
*/
async getMessageLogs(options: {
from: Date;
to: Date;
cursor?: string;
pageSize?: number;
fromFilter?: string;
toFilter?: string;
}): Promise<PaginatedResult<MimecastMessage>> {
const atOptions: Record<string, string> = {};
if (options.fromFilter) atOptions.from = options.fromFilter;
if (options.toFilter) atOptions.to = options.toFilter;
const reqData: Record<string, any> = {
accountCode: this.accountCode,
advancedTrackAndTraceOptions: atOptions,
start: MimecastClient.formatDate(options.from),
end: MimecastClient.formatDate(options.to),
};
if (options.cursor) reqData.pageToken = options.cursor;
const data = await this.request<any>('POST', '/api/message-finder/search', { data: [reqData] });
const block = data.data?.[0] ?? {};
const tracked: any[] = block.trackedEmails ?? [];
return {
items: tracked.map((m: any) => this.normalizeTrackedEmail(m)),
nextCursor: block.pageToken ?? null,
totalCount: tracked.length,
};
}
private normalizeTrackedEmail(m: any): MimecastMessage {
const sender = m.fromEnv?.emailAddress ?? m.fromHdr?.emailAddress ?? m.sender ?? '';
const recipients: string[] = (m.to ?? []).map((t: any) =>
typeof t === 'string' ? t : t.emailAddress ?? ''
);
return {
id: m.id,
senderAddress: sender,
recipientAddress: recipients.join(', '),
subject: m.subject ?? '',
direction: m.route?.toLowerCase().includes('inbound') ? 'inbound' : 'outbound',
status: m.status ?? '',
action: m.detectionLevel ?? undefined,
spamScore: m.spamScore ?? undefined,
sizeBytes: undefined,
attachmentCount: m.attachments ?? 0,
sentDateTime: m.sent ?? undefined,
receivedDateTime: m.received ?? undefined,
route: m.route ?? undefined,
rejectReason: m.info ?? undefined,
sourceIp: m.senderIP ?? undefined,
_raw: m,
};
}
// ── SIEM Batch Events ─────────────────────────────────────────────────────
/**
* GET /siem/v1/batch/events/cg returns pre-signed download URLs for batch NDJSON files
* Each URL points to a compressed file; we download and parse each one.
* Response: { value: [{url, expiry, size}], "@nextPage": string }
*/
async getSiemBatchEventUrls(options: {
from: string; // date only: YYYY-MM-DD
to: string;
type?: string;
cursor?: string;
pageSize?: number;
}): Promise<{ urls: Array<{ url: string; expiry: string; size: number }>; nextCursor: string | null }> {
const params: Record<string, string> = {
type: options.type ?? 'mailflow',
dateRangeStartsAt: options.from,
dateRangeEndsAt: options.to,
pageSize: String(options.pageSize ?? 500),
};
if (options.cursor) params.pageToken = options.cursor;
const data = await this.request<any>('GET', '/siem/v1/batch/events/cg', undefined, params);
return {
urls: data.value ?? [],
nextCursor: data['@nextPage'] ?? null,
};
}
/**
* Download and parse a SIEM batch event file (NDJSON, possibly gzipped)
*/
async downloadSiemBatchFile(url: string): Promise<MimecastMessage[]> {
const res = await fetch(url);
if (!res.ok) return [];
const text = await res.text();
const lines = text.split('\n').filter(l => l.trim());
const messages: MimecastMessage[] = [];
for (const line of lines) {
try {
const event = JSON.parse(line);
messages.push(this.normalizeSiemEvent(event));
} catch {
// skip malformed lines
}
}
return messages;
}
private normalizeSiemEvent(e: any): MimecastMessage {
return {
id: e.messageId ?? e.id ?? e.Messageid ?? '',
senderAddress: e.senderAddress ?? e.Sender ?? e.sender ?? '',
recipientAddress: e.recipientAddress ?? e.Recipient ?? e.recipient ?? '',
subject: e.subject ?? e.Subject ?? '',
direction: (e.Dir ?? e.direction ?? '').toLowerCase() === 'inbound' ? 'inbound' : 'outbound',
status: e.Act ?? e.action ?? e.status ?? '',
action: e.RejType ?? e.rejectionType ?? undefined,
spamScore: e.SpamScore ?? e.spamScore ?? undefined,
sizeBytes: e.MsgSize ?? e.messageSize ?? e.size ?? undefined,
attachmentCount: e.AttCnt ?? e.attachmentCount ?? 0,
sentDateTime: e.Datetime ?? e.datetime ?? e.timestamp ?? undefined,
receivedDateTime: e.Datetime ?? undefined,
route: e.Route ?? e.route ?? undefined,
rejectReason: e.RejCode ?? e.rejectionCode ?? undefined,
heldReason: e.HeldGroup ?? undefined,
sourceIp: e.IP ?? e.senderIp ?? undefined,
_raw: e,
};
}
// ── Threat Events ─────────────────────────────────────────────────────────
/**
* GET /threats/v1/events
* Response: { value: [{timestamp, analysis, source, details, status, direction, subject, sender, ...}], "@nextPage": string }
*/
async getThreatEvents(options: {
cursor?: string;
pageSize?: number;
} = {}): Promise<PaginatedResult<MimecastThreatEvent>> {
const params: Record<string, string> = {
pageSize: String(options.pageSize ?? 500),
};
if (options.cursor) params.pageToken = options.cursor;
try {
const data = await this.request<any>('GET', '/threats/v1/events', undefined, params);
const items = (data.value ?? []).map((e: any) => this.normalizeThreatEvent(e));
return {
items,
nextCursor: data['@nextPage'] ?? null,
};
} catch {
return { items: [], nextCursor: null };
}
}
private normalizeThreatEvent(e: any): MimecastThreatEvent {
const analysis: string[] = Array.isArray(e.analysis) ? e.analysis : [e.analysis].filter(Boolean);
const eventType = analysis[0] ?? 'unknown';
const statusArr: string[] = Array.isArray(e.status) ? e.status : [e.status].filter(Boolean);
const verdict = statusArr[0] ?? undefined;
const threatLevel = analysis.includes('malware') ? 'high'
: analysis.includes('phishing') ? 'high'
: analysis.includes('spam') ? 'medium'
: 'info';
return {
id: e.id ?? `threat_${e.sender ?? ''}_${e.timestamp ?? Date.now()}`,
messageId: e.messageId ?? undefined,
eventType,
threatLevel,
url: e.url ?? undefined,
fileName: undefined,
verdict,
actorEmail: e.sender ?? undefined,
eventDateTime: e.timestamp ?? undefined,
analysis,
source: Array.isArray(e.source) ? e.source : [e.source].filter(Boolean),
direction: Array.isArray(e.direction) ? e.direction : [e.direction].filter(Boolean),
status: statusArr,
_raw: e,
};
}
// ── Message Info (body/headers) ────────────────────────────────────────────
/**
* POST /api/message-finder/get-message-info
* Returns delivered message details including parts
*/
async getMessageInfo(messageId: string): Promise<MimecastMessageInfo | null> {
try {
const data = await this.request<any>('POST', '/api/message-finder/get-message-info', {
data: [{ id: messageId }],
});
const delivered = data.data?.[0]?.deliveredMessage;
if (!delivered) return null;
const entries = Object.values(delivered) as any[];
if (!entries.length) return null;
const info = entries[0]?.messageInfo ?? entries[0];
return {
messageId,
bodyText: info?.textBody ?? info?.body ?? undefined,
bodyHtml: info?.htmlBody ?? undefined,
headers: info?.headers ?? undefined,
};
} catch {
return null;
}
}
// ── Cloud Gateway ─────────────────────────────────────────────────────────
/**
* GET /user/cloud-gateway/v1/users?emailAddress=...&domain=...
*/
async getCloudUser(emailAddress: string, domain: string): Promise<MimecastCloudUser | null> {
const data = await this.request<any>('GET', '/user/cloud-gateway/v1/users', undefined, {
emailAddress,
domain,
});
const user = data?.value?.[0] ?? data?.data?.[0] ?? null;
if (!user) return null;
return {
emailAddress: user.emailAddress ?? emailAddress,
domain: user.domain ?? domain,
lockedOut: user.lockedOut ?? false,
status: user.status ?? undefined,
name: user.name ?? undefined,
alias: user.alias ?? undefined,
_raw: user,
};
}
// ── Held Messages ──────────────────────────────────────────────────────────
/**
* POST /api/gateway/get-hold-message-list
* admin: true = see user-level hold queues as an admin
* pageSize is ignored by the API (always returns 10); paginate via meta.pagination.next
* Fetches ALL pages up to maxMessages limit.
*/
async getHeldMessages(options: {
recipient?: string;
maxMessages?: number;
} = {}): Promise<{ messages: MimecastHeldMessage[]; totalCount: number }> {
const maxMessages = options.maxMessages ?? 500;
const all: MimecastHeldMessage[] = [];
let cursor: string | null = null;
let totalCount = 0;
do {
const reqBody: any = { admin: true };
if (options.recipient) {
reqBody.searchBy = { fieldName: 'recipient', value: options.recipient };
}
const body: any = { data: [reqBody] };
if (cursor) {
body.meta = { pagination: { pageToken: cursor } };
}
const result = await this.request<any>('POST', '/api/gateway/get-hold-message-list', body);
const pagination = result?.meta?.pagination ?? {};
const msgs: any[] = result?.data ?? [];
if (totalCount === 0) totalCount = pagination.totalCount ?? msgs.length;
for (const m of msgs) {
all.push({
id: m.id,
subject: m.subject ?? '',
from: m.fromHeader?.emailAddress ?? m.from?.emailAddress ?? '',
fromDisplay: m.fromHeader?.displayableName ?? m.from?.displayableName ?? '',
to: m.to?.emailAddress ?? '',
toDisplay: m.to?.displayableName ?? '',
dateReceived: m.dateReceived ?? '',
reason: m.reason ?? '',
reasonCode: m.reasonCode ?? '',
policyInfo: m.policyInfo ?? '',
route: m.route ?? '',
hasAttachments: m.hasAttachments ?? false,
size: m.size ?? 0,
});
}
cursor = pagination.next ?? null;
} while (cursor && all.length < maxMessages);
return { messages: all, totalCount };
}
// ── Account ────────────────────────────────────────────────────────────────
/**
* POST /api/account/get-account
*/
async testConnection(): Promise<{ ok: boolean; accountName?: string; packageName?: string; error?: string }> {
try {
const data = await this.request<any>('POST', '/api/account/get-account', { data: [{ accountCode: this.accountCode }] });
const account = data.data?.[0] ?? {};
return {
ok: true,
accountName: account.accountName ?? account.accountCode ?? 'Connected',
packageName: account.packageName ?? undefined,
};
} catch (err: any) {
return { ok: false, error: err.message };
}
}
}
let _client: MimecastClient | null = null;
export function getMimecastClient(): MimecastClient {
if (!_client) {
const clientId = process.env.MIMECAST_CLIENT_ID;
const clientSecret = process.env.MIMECAST_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error('MIMECAST_CLIENT_ID and MIMECAST_CLIENT_SECRET must be set');
}
_client = new MimecastClient({
clientId,
clientSecret,
baseUrl: process.env.MIMECAST_BASE_URL ?? 'https://api.services.mimecast.com',
accountCode: process.env.MIMECAST_ACCOUNT_CODE ?? '',
});
}
return _client;
}
export function getMimecastClientForTenant(tenant: {
client_id: string;
client_secret: string;
base_url?: string;
account_code?: string;
}): MimecastClient {
return new MimecastClient({
clientId: tenant.client_id,
clientSecret: tenant.client_secret,
baseUrl: tenant.base_url ?? 'https://api.services.mimecast.com',
accountCode: tenant.account_code ?? '',
});
}