wulf-pulse/lib/services/triage-note-format.ts
lorentz 14ed8ca248 feat(23-01): add USER_AWARENESS verdict + acknowledge_user action mapping
- Add USER_AWARENESS to the Verdict union in campaign-classifier.ts
- mapVerdictToActions('USER_AWARENESS') returns ['acknowledge_user']; not added to DESTRUCTIVE_ACTIONS so requires_approval computes false
- classifyCampaign's simulation branch now assigns verdict = 'USER_AWARENESS' directly instead of falling through to evaluateSpamVsUnwanted
- deriveDefaultParams('acknowledge_user') returns {} (no operator-editable params)
- Widen TriageNoteEvidence.verdict to admit 'USER_AWARENESS' (pure type widen, no formatting change)
- Tests: classifier simulation fixtures now assert USER_AWARENESS/acknowledge_user/requiresApproval=false; new mapVerdictToActions/computeRequiresApproval/deriveDefaultParams cases
2026-07-16 19:36:53 -04:00

133 lines
4.6 KiB
TypeScript

/**
* 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' | 'USER_AWARENESS' | 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);
}