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
This commit is contained in:
parent
b4e05eb6ad
commit
17ba0e880f
1 changed files with 133 additions and 0 deletions
133
lib/services/triage-note-format.ts
Normal file
133
lib/services/triage-note-format.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue