From 17ba0e880fec958811503b225a187822080831dc Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 12:06:31 -0400 Subject: [PATCH] feat(21-01): implement triage-note formatter + TriageNoteEvidence contract - formatTriageNote renders verdict/confidence/summary/reasons/blast-radius (both branches)/recommended actions/current remediation state as prose - Routes indicator URLs through sanitizeUrl and the whole assembled output through sanitizeNoteText before returning - Handles null verdict/confidence gracefully - Exports TriageNoteEvidence interface for Plan 02 - All 9 formatter tests pass --- lib/services/triage-note-format.ts | 133 +++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 lib/services/triage-note-format.ts diff --git a/lib/services/triage-note-format.ts b/lib/services/triage-note-format.ts new file mode 100644 index 0000000..46a512c --- /dev/null +++ b/lib/services/triage-note-format.ts @@ -0,0 +1,133 @@ +/** + * 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); +}