- 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
133 lines
4.6 KiB
TypeScript
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);
|
|
}
|