diff --git a/lib/services/eml-parser.fixtures.ts b/lib/services/eml-parser.fixtures.ts index 9119d77..cc145fa 100644 --- a/lib/services/eml-parser.fixtures.ts +++ b/lib/services/eml-parser.fixtures.ts @@ -69,3 +69,159 @@ export const NO_EML_ATTACHMENTS: Attachment[] = [ /** Empty attachment list. */ export const EMPTY_ATTACHMENTS: Attachment[] = []; + +// --------------------------------------------------------------------------- +// Synthetic raw .eml buffers for parseEml (EVID-03/EVID-04). All addresses, +// bodies, and content below are invented for testing only — no real +// customer email content per this milestone's Out of Scope constraint. +// --------------------------------------------------------------------------- + +/** + * Rich multipart fixture: text + html bodies (each carrying the same URL), + * one non-inline base64 attachment, and an Authentication-Results header + * with spf=pass, dkim=fail, dmarc=none. + */ +export const RICH_MULTIPART_EML = Buffer.from( + `From: "Attacker Corp" +To: victim@wulfconsulting.test +Cc: cc-user@wulfconsulting.test +Reply-To: reply@evil-example.test +Return-Path: +Subject: Urgent: verify your account +Date: Mon, 15 Jul 2026 12:00:00 +0000 +Message-ID: +Authentication-Results: mx.wulfconsulting.test; spf=pass smtp.mailfrom=evil-example.test; dkim=fail header.d=evil-example.test; dmarc=none header.from=evil-example.test +Received: from mx1.example.test by mx2.example.test; Mon, 15 Jul 2026 11:59:00 +0000 +Received: from mx0.example.test by mx1.example.test; Mon, 15 Jul 2026 11:58:00 +0000 +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="BOUNDARY1" + +--BOUNDARY1 +Content-Type: multipart/alternative; boundary="BOUNDARY2" + +--BOUNDARY2 +Content-Type: text/plain; charset="UTF-8" + +Please visit http://evil-example.test/verify to verify your account. + +--BOUNDARY2 +Content-Type: text/html; charset="UTF-8" + +

Please visit this link to verify your account.

+ +--BOUNDARY2-- + +--BOUNDARY1 +Content-Type: application/pdf; name="invoice.pdf" +Content-Disposition: attachment; filename="invoice.pdf" +Content-Transfer-Encoding: base64 + +SGVsbG8gV29ybGQh + +--BOUNDARY1-- +` +); + +/** Same as RICH_MULTIPART_EML but also carries Authentication-Results-Original. */ +export const RICH_MULTIPART_WITH_AUTH_ORIGINAL_EML = Buffer.from( + `From: "Attacker Corp" +To: victim@wulfconsulting.test +Subject: Urgent: verify your account (remediated) +Date: Mon, 15 Jul 2026 12:00:00 +0000 +Message-ID: +Authentication-Results: mx.wulfconsulting.test; spf=fail smtp.mailfrom=evil-example.test; dkim=fail header.d=evil-example.test; dmarc=fail header.from=evil-example.test +Authentication-Results-Original: mx.wulfconsulting.test; spf=pass smtp.mailfrom=evil-example.test; dkim=pass header.d=evil-example.test; dmarc=pass header.from=evil-example.test +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" + +Body text for the remediated-header fixture. +` +); + +/** + * Inline/CID attachment fixture: html references cid:sig123, wrapped in + * multipart/related so mailparser marks the image attachment `related: true` + * (Pitfall 5 — inline parts must be kept, not dropped). + */ +export const INLINE_ATTACHMENT_EML = Buffer.from( + `From: sender@evil-example.test +To: victim@wulfconsulting.test +Subject: Newsletter with inline logo +Date: Mon, 15 Jul 2026 12:00:00 +0000 +Message-ID: +MIME-Version: 1.0 +Content-Type: multipart/related; boundary="RELBOUND" + +--RELBOUND +Content-Type: multipart/alternative; boundary="ALTBOUND" + +--ALTBOUND +Content-Type: text/plain; charset="UTF-8" + +Plain text body with an inline logo. + +--ALTBOUND +Content-Type: text/html; charset="UTF-8" + +

Hello

+ +--ALTBOUND-- + +--RELBOUND +Content-Type: image/png; name="sig.png" +Content-Disposition: inline +Content-ID: +Content-Transfer-Encoding: base64 + +iVBORw0KGgo= + +--RELBOUND-- +` +); + +/** Long-body fixture — long enough to force buildBodyPreview truncation. */ +const LONG_PARAGRAPH = + 'This is a long paragraph of synthetic phishing-style body text repeated ' + + 'several times to exceed the body preview truncation threshold. '.repeat(20); + +export const LONG_BODY_EML = Buffer.from( + `From: sender@evil-example.test +To: victim@wulfconsulting.test +Subject: Long body fixture +Date: Mon, 15 Jul 2026 12:00:00 +0000 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" + +${LONG_PARAGRAPH} +` +); + +/** Fuzzy (scheme-less) www. URL fixture, to exercise linkify-it's fuzzyLink mode. */ +export const FUZZY_URL_EML = Buffer.from( + `From: sender@evil-example.test +To: victim@wulfconsulting.test +Subject: Fuzzy URL fixture +Date: Mon, 15 Jul 2026 12:00:00 +0000 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" + +Visit www.evil-example.com/login to reset your password. +` +); + +/** Oversized buffer — exceeds MAX_EML_BYTES, must be rejected before simpleParser runs. */ +export function makeOversizedEmlBuffer(maxBytes: number): Buffer { + const header = `From: sender@evil-example.test +To: victim@wulfconsulting.test +Subject: Oversized fixture +Date: Mon, 15 Jul 2026 12:00:00 +0000 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" + +`; + const padding = 'A'.repeat(maxBytes + 1024 - header.length); + return Buffer.from(header + padding); +} diff --git a/lib/services/eml-parser.test.ts b/lib/services/eml-parser.test.ts index f01ff10..ecb0ac9 100644 --- a/lib/services/eml-parser.test.ts +++ b/lib/services/eml-parser.test.ts @@ -1,5 +1,12 @@ -import { describe, it, expect } from 'vitest'; -import { selectOriginalMessage } from './eml-parser'; +import { describe, it, expect, vi } from 'vitest'; +import { + selectOriginalMessage, + parseEml, + parseAuthResults, + extractUrls, + buildBodyPreview, + MAX_EML_BYTES, +} from './eml-parser'; import { RFC_EML_TIER_ATTACHMENTS, RFC_EML_TIER_ATTACHMENTS_UPPERCASE, @@ -9,6 +16,12 @@ import { AMBIGUOUS_NO_FALLBACK_ATTACHMENTS, NO_EML_ATTACHMENTS, EMPTY_ATTACHMENTS, + RICH_MULTIPART_EML, + RICH_MULTIPART_WITH_AUTH_ORIGINAL_EML, + INLINE_ATTACHMENT_EML, + LONG_BODY_EML, + FUZZY_URL_EML, + makeOversizedEmlBuffer, } from './eml-parser.fixtures'; describe('selectOriginalMessage', () => { @@ -55,3 +68,152 @@ describe('selectOriginalMessage', () => { expect(selectOriginalMessage(EMPTY_ATTACHMENTS)).toBeNull(); }); }); + +describe('parseAuthResults', () => { + it('parses spf/dkim/dmarc verdicts from a raw Authentication-Results header value', () => { + const result = parseAuthResults( + 'mx.wulfconsulting.test; spf=pass smtp.mailfrom=evil-example.test; dkim=fail header.d=evil-example.test; dmarc=none header.from=evil-example.test' + ); + expect(result).toEqual({ spf: 'pass', dkim: 'fail', dmarc: 'none' }); + }); + + it('is case-insensitive on method and result tokens', () => { + const result = parseAuthResults('mx.test; SPF=PASS; DKIM=Fail; DMARC=None'); + expect(result).toEqual({ spf: 'pass', dkim: 'fail', dmarc: 'none' }); + }); + + it('omits methods not present in the header', () => { + const result = parseAuthResults('mx.test; spf=softfail'); + expect(result).toEqual({ spf: 'softfail' }); + }); +}); + +describe('extractUrls', () => { + it('extracts and dedupes URLs from both text and html parts', () => { + const urls = extractUrls( + 'Visit http://evil-example.test/verify now.', + 'link' + ); + expect(urls).toEqual(['http://evil-example.test/verify']); + }); + + it('extracts fuzzy (scheme-less) www. URLs', () => { + const urls = extractUrls('Visit www.evil-example.com/login to reset.', null); + expect(urls.some((u) => u.includes('evil-example.com/login'))).toBe(true); + }); + + it('returns an empty array when no URLs are present', () => { + expect(extractUrls('no links here', null)).toEqual([]); + }); + + it('handles null/undefined text and html gracefully', () => { + expect(extractUrls(null, undefined)).toEqual([]); + }); +}); + +describe('buildBodyPreview', () => { + it('prefers plain text over html', () => { + const preview = buildBodyPreview('plain text body', '

html body

'); + expect(preview).toContain('plain text body'); + }); + + it('falls back to a stripped version of html when text is absent', () => { + const preview = buildBodyPreview(null, '

Hello world

'); + expect(preview).toContain('Hello'); + expect(preview).toContain('world'); + expect(preview).not.toContain('

'); + expect(preview).not.toContain(''); + }); + + it('truncates a long body and stays distinct from the raw text', () => { + const longText = 'x'.repeat(2000); + const preview = buildBodyPreview(longText, null); + expect(preview.length).toBeLessThan(longText.length); + expect(preview).not.toBe(longText); + }); +}); + +describe('parseEml', () => { + it('normalizes headers, auth results, received chain, urls, and attachment metadata from a synthetic fixture', async () => { + const result = await parseEml(RICH_MULTIPART_EML); + + expect(result.from.email).toBe('attacker@evil-example.test'); + expect(result.from.displayName).toBe('Attacker Corp'); + expect(result.from.domain).toBe('evil-example.test'); + expect(result.replyTo).toBe('reply@evil-example.test'); + expect(result.returnPath).toBe('bounce@evil-example.test'); + expect(result.to).toContain('victim@wulfconsulting.test'); + expect(result.cc).toContain('cc-user@wulfconsulting.test'); + expect(result.subject).toBe('Urgent: verify your account'); + expect(result.date).toBeTruthy(); + expect(result.messageId).toBe(''); + expect(result.receivedChain).toHaveLength(2); + + expect(result.authResults).toEqual({ spf: 'pass', dkim: 'fail', dmarc: 'none' }); + expect(result.authResultsOriginal).toBeNull(); + + expect(result.urls).toContain('http://evil-example.test/verify'); + + expect(result.attachments).toHaveLength(1); + expect(result.attachments[0].filename).toBe('invoice.pdf'); + expect(result.attachments[0].contentType).toBe('application/pdf'); + expect(result.attachments[0].checksum).toMatch(/^[a-f0-9]{64}$/); + expect(result.attachments[0].related).toBe(false); + + expect(result.bodyPreview).toBeTruthy(); + }); + + it('populates authResultsOriginal when an Authentication-Results-Original header is present', async () => { + const result = await parseEml(RICH_MULTIPART_WITH_AUTH_ORIGINAL_EML); + expect(result.authResults).toEqual({ spf: 'fail', dkim: 'fail', dmarc: 'fail' }); + expect(result.authResultsOriginal).toEqual({ spf: 'pass', dkim: 'pass', dmarc: 'pass' }); + }); + + it('preserves inline/related attachments rather than dropping them', async () => { + const result = await parseEml(INLINE_ATTACHMENT_EML); + expect(result.attachments).toHaveLength(1); + expect(result.attachments[0].related).toBe(true); + }); + + it('produces a body preview that is truncated and distinct from the raw body', async () => { + const result = await parseEml(LONG_BODY_EML); + expect(result.bodyPreview.length).toBeLessThan(2000); + }); + + it('extracts fuzzy www. URLs from a real parsed message', async () => { + const result = await parseEml(FUZZY_URL_EML); + expect(result.urls.some((u) => u.includes('evil-example.com/login'))).toBe(true); + }); + + it('never makes a network call while parsing any fixture', async () => { + const fetchSpy = vi.spyOn(global, 'fetch'); + await parseEml(RICH_MULTIPART_EML); + await parseEml(RICH_MULTIPART_WITH_AUTH_ORIGINAL_EML); + await parseEml(INLINE_ATTACHMENT_EML); + await parseEml(LONG_BODY_EML); + await parseEml(FUZZY_URL_EML); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + it('rejects a buffer larger than MAX_EML_BYTES before simpleParser is called', async () => { + const oversized = makeOversizedEmlBuffer(MAX_EML_BYTES); + expect(oversized.byteLength).toBeGreaterThan(MAX_EML_BYTES); + await expect(parseEml(oversized)).rejects.toThrow(); + }); + + it('does not contain any real customer email content in any fixture', () => { + const allFixtures = [ + RICH_MULTIPART_EML, + RICH_MULTIPART_WITH_AUTH_ORIGINAL_EML, + INLINE_ATTACHMENT_EML, + LONG_BODY_EML, + FUZZY_URL_EML, + ]; + for (const fixture of allFixtures) { + const text = fixture.toString('utf-8'); + expect(text).not.toMatch(/wulfconsulting\.com/); + expect(text.includes('evil-example.test') || text.includes('evil-example.com')).toBe(true); + } + }); +});