/** * Triage-note formatter (Phase 21, NOTE-01). * * Pure, deterministic function that turns a structured campaign-evidence * object (`TriageNoteEvidence`) into human-readable prose suitable for * posting as an internal Autotask `TicketNotes` entry. No DB, network, or * Autotask dependency — Plan 02's service gathers evidence and calls * `formatTriageNote()`, then writes the resulting text. * * Every URL and the entire assembled string are routed through * `triage-note-sanitize.ts` before being returned, so a secret/token/full * malicious query string embedded in ANY source field (summary, reasons, * URLs) is stripped regardless of which field it came from (D-04 "full * picture" + NOTE-01 sanitization requirement). */ import { sanitizeUrl, sanitizeNoteText } from './triage-note-sanitize'; import type { BlastRadiusResult } from './mimecast-blast-radius'; export interface TriageNoteEvidence { campaignId: string; reportCount: number; companyName?: string | null; subject?: string | null; verdict: 'SPAM' | 'UNWANTED' | 'THREAT' | null; confidence: number | null; summary: string | null; reasons: string[]; recommendedActions: string[]; requiresApproval: boolean; blastRadius: BlastRadiusResult; remediationActions: Array<{ actionType: string; status: string; approvedBy: string | null; approvedAt: string | null; }>; urls: string[]; } function formatBlastRadiusSection(blastRadius: BlastRadiusResult): string { if (blastRadius.status === 'unavailable') { const detail = blastRadius.error ? ` — ${blastRadius.error}` : ''; return `Blast Radius: unavailable (reason: ${blastRadius.reason})${detail}`; } return [ 'Blast Radius:', ` Matched: ${blastRadius.matched}`, ` Delivered: ${blastRadius.delivered}`, ` Held: ${blastRadius.held}`, ` Rejected: ${blastRadius.rejected}`, ` Clicked: ${blastRadius.clicked}`, ].join('\n'); } function formatRemediationSection( remediationActions: TriageNoteEvidence['remediationActions'] ): string { if (remediationActions.length === 0) { return 'Current Remediation State: No action has been taken yet — recommended actions are proposed-only.'; } const lines = remediationActions.map((action) => { const approver = action.approvedBy || action.approvedAt ? ` (approver: ${action.approvedBy ?? 'unknown'}, approved at: ${action.approvedAt ?? 'unknown'})` : ''; return ` - ${action.actionType} — ${action.status}${approver}`; }); return ['Current Remediation State:', ...lines].join('\n'); } /** * Assemble the full triage note as human-readable prose, sanitized so no * secret/token/full malicious URL query string survives regardless of which * evidence field it originated from. */ export function formatTriageNote(evidence: TriageNoteEvidence): string { const verdictLabel = evidence.verdict ?? 'not yet classified'; const confidenceLabel = evidence.confidence === null || evidence.confidence === undefined ? 'not yet classified' : String(evidence.confidence); const headerLine = `Phishing Triage Summary — Campaign ${evidence.campaignId} (${evidence.reportCount} report${evidence.reportCount === 1 ? '' : 's'})`; const classificationSection = [ 'Classification:', ` Verdict: ${verdictLabel}`, ` Confidence: ${confidenceLabel}`, ` Summary: ${evidence.summary ?? 'not yet classified'}`, ].join('\n'); const reasonsSection = evidence.reasons.length > 0 ? ['Reasons:', ...evidence.reasons.map((r) => ` - ${r}`)].join('\n') : 'Reasons: none recorded'; const blastRadiusSection = formatBlastRadiusSection(evidence.blastRadius); const recommendedActionsSection = [ 'Recommended Actions:', evidence.recommendedActions.length > 0 ? evidence.recommendedActions.map((a) => ` - ${a}`).join('\n') : ' - none', evidence.requiresApproval ? ' (requires operator approval)' : ' (no approval required)', ].join('\n'); const remediationSection = formatRemediationSection(evidence.remediationActions); const urlsSection = evidence.urls.length > 0 ? ['Indicator URLs:', ...evidence.urls.map((u) => ` - ${sanitizeUrl(u)}`)].join('\n') : null; const sections = [ headerLine, evidence.companyName ? `Company: ${evidence.companyName}` : null, evidence.subject ? `Subject: ${evidence.subject}` : null, classificationSection, reasonsSection, blastRadiusSection, recommendedActionsSection, remediationSection, urlsSection, ].filter((s): s is string => s !== null); const assembled = sections.join('\n\n'); return sanitizeNoteText(assembled); }