- parseAndStoreMessage (Defect 3): short-circuit with
{ stored: false, reason: 'already-parsed' } when a messages row already
exists for the report, before any Autotask attachment fetch
- webhook-service (Defect 2): new retryPhishingParseOnUpdate wired into
ticket.update fire-and-forget path; retries the missing-EML parse for a
flagged, unparsed, auto_parse-gated report — no new cron/polling, reuses
existing update traffic, safe to fire repeatedly thanks to the new
idempotency guard
- Adjust eml-service test mock default so the new leading existence-check
query doesn't short-circuit existing happy-path tests; add new test for
the already-parsed short-circuit
238 lines
8.2 KiB
TypeScript
238 lines
8.2 KiB
TypeScript
/**
|
|
* Phishing EML/MIME evidence orchestration service (Phase 16, Plan 03).
|
|
*
|
|
* Turns a detected phishing report into persisted, normalized message
|
|
* evidence: lists a ticket's attachments, selects the original reported
|
|
* message (Plan 01's `selectOriginalMessage`), fetches its full base64
|
|
* content (Plan 02's `AutotaskClient.getAttachmentContent`), size-guards the
|
|
* decoded buffer, stores the raw bytes in B2 under a dedicated `.eml` key
|
|
* when configured (D-05), parses it (Plan 01's `parseEml`), and persists one
|
|
* `messages` row plus `indicators` rows carrying per-indicator context in
|
|
* the D-07 `metadata` JSONB column.
|
|
*
|
|
* Hard invariant (SC#3 / T-16-03): this service never fetches or executes
|
|
* anything found in the parsed message. The only outbound network calls are
|
|
* the Autotask attachment GET (via `AutotaskClient`) and the B2 presigned
|
|
* PUT of the raw bytes we already hold — never a URL/host derived from the
|
|
* message content itself.
|
|
*
|
|
* The live on-demand trigger (`POST /api/phishing/tickets/{id}/analyze`)
|
|
* arrives in Phase 18 (DETECT-03); this module is callable and fully tested
|
|
* here.
|
|
*/
|
|
|
|
import { postgresClient } from './postgres-client';
|
|
import { getAutotaskClient } from './autotask-factory';
|
|
import { presignUpload, isB2Configured, EML_OBJECT_KEY_REGEX } from './b2/client';
|
|
import { parseEml, selectOriginalMessage, MAX_EML_BYTES, type NormalizedMessage } from './eml-parser';
|
|
|
|
export interface ParseAndStoreInput {
|
|
reportId: string;
|
|
ticketId: number;
|
|
}
|
|
|
|
export interface ParseAndStoreResult {
|
|
stored: boolean;
|
|
messageId?: string;
|
|
reason?: string;
|
|
}
|
|
|
|
/**
|
|
* Orchestrates list -> select -> fetch -> (B2 gated) -> parse -> persist for
|
|
* a single report/ticket.
|
|
*
|
|
* Returns `{ stored: false, reason: ... }` (never throws) when there is
|
|
* nothing to persist yet (no `.eml` attachment found, no content returned,
|
|
* or the decoded buffer is oversized). Rethrows on any other failure so a
|
|
* future caller (Phase 18) can decide fail-vs-degrade (T-16-08).
|
|
*/
|
|
export async function parseAndStoreMessage(
|
|
input: ParseAndStoreInput
|
|
): Promise<ParseAndStoreResult> {
|
|
const { reportId, ticketId } = input;
|
|
|
|
try {
|
|
// Idempotency guard (Defect 3, quick task 260717-a19): a ticket.update
|
|
// webhook may retry parsing for a report that already has a messages
|
|
// row (e.g. the CREATE-time attempt succeeded after all, or a prior
|
|
// UPDATE retry already parsed it). Short-circuit before any Autotask
|
|
// attachment fetch so a repeated call is a cheap no-op, never a
|
|
// duplicate messages row.
|
|
const existing = await postgresClient.query<{ id: string }>(
|
|
`SELECT id FROM messages WHERE report_id = $1 LIMIT 1`,
|
|
[reportId]
|
|
);
|
|
if (existing.rows.length > 0) {
|
|
return { stored: false, reason: 'already-parsed' };
|
|
}
|
|
|
|
// reports.evidence only stores fullPath/title/contentType (no attachment
|
|
// id) — list live so we have the attachment id needed for the
|
|
// per-attachment content fetch below.
|
|
const attachments = await getAutotaskClient().getAttachments('Tickets', ticketId);
|
|
const selected = selectOriginalMessage(attachments);
|
|
|
|
if (!selected) {
|
|
return { stored: false, reason: 'no-eml-attachment' };
|
|
}
|
|
|
|
const fullAttachment = await getAutotaskClient().getAttachmentContent(
|
|
'Tickets',
|
|
ticketId,
|
|
selected.id
|
|
);
|
|
|
|
if (!fullAttachment?.data) {
|
|
console.error(
|
|
'[PHISHING-EML] Selected attachment had no content for report',
|
|
reportId,
|
|
'ticket',
|
|
ticketId,
|
|
'attachment',
|
|
selected.id
|
|
);
|
|
return { stored: false, reason: 'no-attachment-content' };
|
|
}
|
|
|
|
const rawEmlBuffer = Buffer.from(fullAttachment.data, 'base64');
|
|
|
|
// Size guard BEFORE parseEml (T-16-01 — DoS mitigation): degrade rather
|
|
// than let parseEml's own internal guard throw and crash the caller.
|
|
if (rawEmlBuffer.byteLength > MAX_EML_BYTES) {
|
|
console.error(
|
|
'[PHISHING-EML] Decoded attachment exceeds MAX_EML_BYTES, skipping report',
|
|
reportId,
|
|
'size',
|
|
rawEmlBuffer.byteLength
|
|
);
|
|
return { stored: false, reason: 'oversized-attachment' };
|
|
}
|
|
|
|
let rawRef: string | null = null;
|
|
if (isB2Configured()) {
|
|
const objectKey = `phishing/${reportId}/${selected.id}.eml`;
|
|
try {
|
|
const uploadUrl = presignUpload(objectKey, 1800, undefined, EML_OBJECT_KEY_REGEX);
|
|
const putResponse = await fetch(uploadUrl, { method: 'PUT', body: rawEmlBuffer });
|
|
if (!putResponse.ok) {
|
|
throw new Error(`B2 PUT ${objectKey} failed: ${putResponse.status}`);
|
|
}
|
|
rawRef = objectKey;
|
|
} catch (error) {
|
|
// Graceful degrade (RESEARCH.md Pitfall 3) — a failed/absent B2 PUT
|
|
// must not abort parsing/persistence.
|
|
console.error(
|
|
'[PHISHING-EML] Failed to upload raw .eml to B2 for report',
|
|
reportId,
|
|
error
|
|
);
|
|
rawRef = null;
|
|
}
|
|
} else {
|
|
console.log(
|
|
'[PHISHING-EML] B2 not configured; skipping raw .eml upload for report',
|
|
reportId
|
|
);
|
|
}
|
|
|
|
const normalized: NormalizedMessage = await parseEml(rawEmlBuffer);
|
|
|
|
// Full normalized header block, including structured SPF/DKIM/DMARC
|
|
// verdicts (D-06), persisted into messages.headers JSONB.
|
|
const headersPayload = {
|
|
from: normalized.from,
|
|
replyTo: normalized.replyTo,
|
|
returnPath: normalized.returnPath,
|
|
to: normalized.to,
|
|
cc: normalized.cc,
|
|
subject: normalized.subject,
|
|
date: normalized.date,
|
|
messageId: normalized.messageId,
|
|
receivedChain: normalized.receivedChain,
|
|
authResults: normalized.authResults,
|
|
authResultsOriginal: normalized.authResultsOriginal,
|
|
};
|
|
|
|
const messageInsert = await postgresClient.query<{ id: string }>(
|
|
`INSERT INTO messages (
|
|
report_id, message_id, headers, urls, attachments, body_preview, raw_ref
|
|
)
|
|
VALUES ($1, $2, $3::jsonb, $4::jsonb, $5::jsonb, $6, $7)
|
|
RETURNING id::text AS id`,
|
|
[
|
|
reportId,
|
|
normalized.messageId,
|
|
JSON.stringify(headersPayload),
|
|
JSON.stringify(normalized.urls),
|
|
JSON.stringify(normalized.attachments),
|
|
normalized.bodyPreview,
|
|
rawRef,
|
|
]
|
|
);
|
|
|
|
const messageId = messageInsert.rows[0].id;
|
|
|
|
// One indicator per attachment checksum (D-07 metadata carries
|
|
// filename/contentType/size/related so Phase 19 can weight inline parts
|
|
// differently without a second lookup).
|
|
for (const attachment of normalized.attachments) {
|
|
if (!attachment.checksum) continue;
|
|
await postgresClient.query(
|
|
`INSERT INTO indicators (message_id, indicator_type, value, metadata)
|
|
VALUES ($1, $2, $3, $4::jsonb)
|
|
RETURNING id::text AS id`,
|
|
[
|
|
messageId,
|
|
'attachment_hash',
|
|
attachment.checksum,
|
|
JSON.stringify({
|
|
filename: attachment.filename,
|
|
contentType: attachment.contentType,
|
|
size: attachment.size,
|
|
related: attachment.related,
|
|
}),
|
|
]
|
|
);
|
|
}
|
|
|
|
// One indicator per extracted URL — never dereferenced, only persisted
|
|
// as a string value (SC#3 / T-16-03).
|
|
for (const url of normalized.urls) {
|
|
await postgresClient.query(
|
|
`INSERT INTO indicators (message_id, indicator_type, value, metadata)
|
|
VALUES ($1, $2, $3, $4::jsonb)
|
|
RETURNING id::text AS id`,
|
|
[messageId, 'url', url, JSON.stringify({ part: 'body' })]
|
|
);
|
|
}
|
|
|
|
// One indicator for the sender, if present.
|
|
if (normalized.from.email) {
|
|
await postgresClient.query(
|
|
`INSERT INTO indicators (message_id, indicator_type, value, metadata)
|
|
VALUES ($1, $2, $3, $4::jsonb)
|
|
RETURNING id::text AS id`,
|
|
[
|
|
messageId,
|
|
'sender',
|
|
normalized.from.email,
|
|
JSON.stringify({
|
|
displayName: normalized.from.displayName,
|
|
domain: normalized.from.domain,
|
|
}),
|
|
]
|
|
);
|
|
}
|
|
|
|
return { stored: true, messageId };
|
|
} catch (error) {
|
|
console.error(
|
|
'[PHISHING-EML] Failed to parse/store message for report',
|
|
reportId,
|
|
'ticket',
|
|
ticketId,
|
|
error
|
|
);
|
|
throw error;
|
|
}
|
|
}
|