/** * EML/MIME evidence parser for the phishing-triage pipeline (Phase 16). * * Turns a raw RFC822/MIME `.eml` buffer (an attacker-controlled email a * user reported as phishing/spam) into a normalized, structured * `NormalizedMessage` — headers, structured SPF/DKIM/DMARC verdicts, * URLs, and attachment metadata — using `mailparser` for MIME parsing * and a small hand-rolled RFC 8601 tokenizer for Authentication-Results. * * Hard invariant (SC#3 / EVID-04 / T-16-03): this module must never fetch * or execute anything found in a message. It never dereferences an * extracted URL, never renders `mail.html`, and never performs any * outbound network call while parsing. This is test-enforced with a * `global.fetch` spy in eml-parser.test.ts. * * Also implements `selectOriginalMessage` (EVID-02) — a pure, I/O-free * decision over ticket attachment metadata already in hand, choosing the * originally-reported message from a ticket's attachment list. */ import { simpleParser } from 'mailparser'; import type { AddressObject, HeaderValue, ParsedMail } from 'mailparser'; import { linkifyit } from 'linkify-it'; import type { Attachment } from '@/lib/types/autotask'; /** Basename of an attachment's filename, lowercased, for tier matching. */ function attachmentName(att: Attachment): string { const raw = att.fullPath || att.title || ''; const base = raw.split('/').pop() || raw; return base.toLowerCase(); } function isMessageRfc822(att: Attachment): boolean { return (att.contentType || '').toLowerCase() === 'message/rfc822'; } /** * Three-tier `.eml` attachment selection, empirically validated against 15 * real phishing tickets (see 16-RESEARCH.md Pitfall 1): * * 1. An attachment named exactly `rfc.eml` (case-insensitive) among * `message/rfc822` attachments — the Microsoft "Report Message" flow. * 2. Else, among `message/rfc822` attachments, exclude any named exactly * `OriginatingEmail.eml` (case-insensitive) — if exactly one candidate * remains, select it (covers KnowBe4's versioned filenames, e.g. * `phish_alert_sp2_2.0.0.0.eml`). * 3. Else (0 or 2+ ambiguous candidates after step 2) — fall back to * `OriginatingEmail.eml` if present; otherwise return null. */ export function selectOriginalMessage(attachments: Attachment[]): Attachment | null { const rfc822Attachments = attachments.filter(isMessageRfc822); const exactRfcEml = rfc822Attachments.find((att) => attachmentName(att) === 'rfc.eml'); if (exactRfcEml) return exactRfcEml; const nonOriginatingCandidates = rfc822Attachments.filter( (att) => attachmentName(att) !== 'originatingemail.eml' ); if (nonOriginatingCandidates.length === 1) return nonOriginatingCandidates[0]; const originatingFallback = rfc822Attachments.find( (att) => attachmentName(att) === 'originatingemail.eml' ); return originatingFallback ?? null; } // --------------------------------------------------------------------------- // parseEml + auth-results + URLs + body preview (EVID-03, EVID-04, D-06) // --------------------------------------------------------------------------- /** Hard cap on bytes this module will hand to `simpleParser`. Below B2's 25 MB cap (T-16-01). */ export const MAX_EML_BYTES = 10 * 1024 * 1024; // 10 MB /** Max length of the derived plain-text body preview (EVID-04). */ const MAX_BODY_PREVIEW_LENGTH = 500; export type AuthVerdict = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror'; export interface AuthResults { spf?: AuthVerdict; dkim?: AuthVerdict; dmarc?: AuthVerdict; } export interface AttachmentMeta { filename: string | null; contentType: string | null; size: number; checksum: string | null; related: boolean; } export interface NormalizedMessage { from: { displayName: string | null; email: string | null; domain: string | null }; replyTo: string | null; returnPath: string | null; to: string[]; cc: string[]; subject: string | null; date: string | null; // ISO 8601 or null messageId: string | null; receivedChain: string[]; // ordered, outermost-first as encountered in headerLines authResults: AuthResults; // parsed from the primary Authentication-Results header authResultsOriginal: AuthResults | null; // parsed from Authentication-Results-Original if present urls: string[]; // deduped, from text + html parts attachments: AttachmentMeta[]; // includes inline/related, distinguished by `related` bodyPreview: string; // truncated plain text, distinct from raw body } /** * Hand-rolled RFC 8601 Authentication-Results tokenizer (D-06). Deliberately * NOT delegated to `mailauth` — that package's public API performs live * DNS/HTTP verification (SC#3 violation); this only re-reads the verdict a * receiving mail server already stamped on the header. * * Grammar (simplified): `authserv-id; method1=result1 (comment); method2=result2 ...` */ export function parseAuthResults(headerValue: string): AuthResults { const result: AuthResults = {}; for (const clause of headerValue.split(';')) { const match = clause.trim().match(/^(spf|dkim|dmarc)=(\w+)/i); if (!match) continue; const method = match[1].toLowerCase() as 'spf' | 'dkim' | 'dmarc'; const verdict = match[2].toLowerCase() as AuthVerdict; result[method] = verdict; } return result; } // fuzzyLink enables scheme-less `www.`-prefixed URL detection (phishing URLs // frequently omit the scheme). Never used to fetch/dereference — string // matching only. const linkify = linkifyit({ fuzzyLink: true }); /** * Extracts and dedupes http(s)/www URLs found in the text and html parts of * a message. Never fetches or dereferences any extracted URL (SC#3 / T-16-03) * — this is pure string matching via `linkify-it`. */ export function extractUrls( text: string | null | undefined, html: string | null | undefined ): string[] { const urls = new Set(); for (const source of [text, html]) { if (!source) continue; const matches = linkify.match(source) ?? []; for (const match of matches) { if (match.schema === 'mailto:') continue; urls.add(match.url); } } return Array.from(urls); } /** Strips HTML tags/entities down to plain text. Never renders/executes the HTML. */ function stripHtmlToText(html: string): string { return html .replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, ' ') .replace(/<[^>]+>/g, ' ') .replace(/ /gi, ' ') .replace(/&/gi, '&') .replace(/</gi, '<') .replace(/>/gi, '>') .replace(/"/gi, '"') .replace(/'/gi, "'") .replace(/\s+/g, ' ') .trim(); } /** * Builds a sanitized, truncated plain-text body preview — prefers the * already-safe `mail.text`, falling back to a stripped version of * `mail.html` when no plain-text part exists. Never returns raw HTML. */ export function buildBodyPreview( text: string | null | undefined, html: string | null | undefined ): string { const source = text && text.trim() ? text : html ? stripHtmlToText(html) : ''; const normalized = source.replace(/\s+/g, ' ').trim(); if (normalized.length <= MAX_BODY_PREVIEW_LENGTH) return normalized; return normalized.slice(0, MAX_BODY_PREVIEW_LENGTH).trimEnd() + '…'; } /** Extracts a single email address string from a mailparser HeaderValue (used for Return-Path). */ function headerValueToAddress(value: HeaderValue | undefined): string | null { if (!value) return null; if (typeof value === 'string') { const cleaned = value.replace(/^$/, '').trim(); return cleaned || null; } if (Array.isArray(value)) return null; if (typeof value === 'object' && 'value' in value) { const addressObject = value as AddressObject; return addressObject.value?.[0]?.address ?? null; } return null; } /** Flattens one or more mailparser AddressObjects into a list of email address strings. */ function addressListToStrings(addr: AddressObject | AddressObject[] | undefined): string[] { if (!addr) return []; const objects = Array.isArray(addr) ? addr : [addr]; const addresses: string[] = []; for (const obj of objects) { for (const entry of obj.value ?? []) { if (entry.address) addresses.push(entry.address); } } return addresses; } /** Returns the raw header text (post-colon) for the first headerLine matching `key`, or null. */ function findHeaderLineValue(mail: ParsedMail, key: string): string | null { const line = mail.headerLines.find((candidate) => candidate.key === key); if (!line) return null; const colonIndex = line.line.indexOf(':'); return (colonIndex === -1 ? line.line : line.line.slice(colonIndex + 1)).trim(); } /** * Parses a raw RFC822/MIME `.eml` buffer into a normalized, structured * `NormalizedMessage`. Enforces `MAX_EML_BYTES` BEFORE calling `simpleParser` * (DoS mitigation, T-16-01) and never performs any network I/O (SC#3). */ export async function parseEml(rawEmlBuffer: Buffer): Promise { if (rawEmlBuffer.byteLength > MAX_EML_BYTES) { throw new Error( `[EML-PARSER] Buffer size ${rawEmlBuffer.byteLength} exceeds MAX_EML_BYTES (${MAX_EML_BYTES}); refusing to parse` ); } let mail: ParsedMail; try { mail = await simpleParser(rawEmlBuffer, { checksumAlgo: 'sha256' }); } catch (error) { console.error('[EML-PARSER] Failed to parse .eml buffer', error); throw error; } const fromEntry = mail.from?.value?.[0]; const fromEmail = fromEntry?.address ?? null; const fromDomain = fromEmail?.includes('@') ? fromEmail.split('@')[1] ?? null : null; const receivedChain: string[] = []; for (const line of mail.headerLines) { if (line.key === 'received') receivedChain.push(line.line); } const primaryAuthResultsHeader = findHeaderLineValue(mail, 'authentication-results'); const originalAuthResultsHeader = findHeaderLineValue(mail, 'authentication-results-original'); return { from: { displayName: fromEntry?.name || null, email: fromEmail, domain: fromDomain, }, replyTo: mail.replyTo?.value?.[0]?.address ?? null, returnPath: headerValueToAddress(mail.headers.get('return-path')), to: addressListToStrings(mail.to), cc: addressListToStrings(mail.cc), subject: mail.subject ?? null, date: mail.date ? mail.date.toISOString() : null, messageId: mail.messageId ?? null, receivedChain, authResults: primaryAuthResultsHeader ? parseAuthResults(primaryAuthResultsHeader) : {}, authResultsOriginal: originalAuthResultsHeader ? parseAuthResults(originalAuthResultsHeader) : null, urls: extractUrls(mail.text, mail.html || null), attachments: mail.attachments.map((att) => ({ filename: att.filename ?? null, contentType: att.contentType ?? null, size: att.size, checksum: att.checksum ?? null, related: Boolean(att.related), })), bodyPreview: buildBodyPreview(mail.text, mail.html || null), }; }