- generateAndPostAccidentalReportNote mirrors generateAndPostAcknowledgment: fixed customer-visible template (noteType 18/publish 1), per-ticket try/catch isolation, zero evidence interpolation (T-23-01) - markCampaignAccidentalReport mirrors markCampaignFalsePositive's D-04 guard/transaction shape, then posts the note post-commit outside the FOR UPDATE lock; note-post failure never propagates
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
/**
|
|
* Triage-note service (Phase 21, NOTE-01).
|
|
*
|
|
* Gathers a campaign's CURRENT evidence — linked reports/tickets, extracted
|
|
* url indicators (Phase 16), most-recent classification (Phase 19), current
|
|
* remediation state (Phase 20), and a fresh blast-radius lookup (Phase 17) —
|
|
* renders it through Plan 01's `formatTriageNote()` (already sanitized), and
|
|
* posts one internal (non-portal) Autotask `TicketNotes` entry per linked
|
|
* ticket via the existing safe write path (`workflow-engine.ts`'s
|
|
* `createEntity('TicketNotes', ...)` precedent).
|
|
*
|
|
* D-05/D-06: each ticket's write is attempted in its own try/catch INSIDE the
|
|
* loop — one ticket's Autotask failure never aborts the remaining writes, and
|
|
* the generated note text is always returned regardless of write outcome.
|
|
*/
|
|
|
|
import postgresClient from './postgres-client';
|
|
import { getAutotaskClient } from './autotask-factory';
|
|
import { getBlastRadius, type BlastRadiusResult } from './mimecast-blast-radius';
|
|
import { formatTriageNote, type TriageNoteEvidence } from './triage-note-format';
|
|
|
|
export interface TriageNotePostResult {
|
|
ticketId: string;
|
|
posted: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
export interface TriageNoteResult {
|
|
noteText: string;
|
|
tickets: TriageNotePostResult[];
|
|
}
|
|
|
|
interface ReportRow {
|
|
id: string;
|
|
ticket_id: string;
|
|
ticket_number: string | null;
|
|
title: string | null;
|
|
company_name: string | null;
|
|
requester_contact_id: number | null;
|
|
evidence: unknown;
|
|
created_at: string;
|
|
}
|
|
|
|
interface ClassificationRow {
|
|
verdict: string | null;
|
|
confidence: number | string | null;
|
|
summary: string | null;
|
|
reasons: string[] | string | null;
|
|
recommended_actions: string[] | string | null;
|
|
requires_approval: boolean | null;
|
|
created_at: string;
|
|
}
|
|
|
|
interface RemediationRow {
|
|
action_type: string;
|
|
status: string;
|
|
approved_by: string | null;
|
|
approved_at: string | null;
|
|
}
|
|
|
|
interface IndicatorUrlRow {
|
|
value: string;
|
|
}
|
|
|
|
/** Normalizes a JSONB array column into a string[] regardless of driver JSON parsing (mirrors remediation-service.ts's parseRecommendedActions idiom). */
|
|
function parseJsonArray(value: string[] | string | null | undefined): string[] {
|
|
if (Array.isArray(value)) return value;
|
|
if (typeof value === 'string') {
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Gathers current campaign evidence, renders the sanitized triage note, and
|
|
* posts it as an internal TicketNote to every ticket linked to the campaign
|
|
* (D-01). Always returns the note text (D-06) — a per-ticket write failure is
|
|
* captured on that ticket's result entry without aborting the loop (D-05).
|
|
*/
|
|
export async function generateAndPostTriageNote(campaignId: string): Promise<TriageNoteResult> {
|
|
const reportsRes = await postgresClient.query<ReportRow>(
|
|
`SELECT id::text, ticket_id::text AS ticket_id, ticket_number, title, company_name,
|
|
requester_contact_id, evidence, created_at::text AS created_at
|
|
FROM reports WHERE campaign_id = $1 ORDER BY created_at ASC`,
|
|
[campaignId]
|
|
);
|
|
const reports = reportsRes.rows;
|
|
|
|
const classificationRes = await postgresClient.query<ClassificationRow>(
|
|
`SELECT verdict, confidence::float8 AS confidence, summary, reasons, recommended_actions,
|
|
requires_approval, created_at::text AS created_at
|
|
FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC LIMIT 1`,
|
|
[campaignId]
|
|
);
|
|
// NUMERIC confidence comes back from node-pg as a JS string when read as a
|
|
// bare column; the `::float8` cast above makes real Postgres return a real
|
|
// number, but we still defensively coerce here so the
|
|
// `TriageNoteEvidence.confidence: number | null` contract holds even if a
|
|
// caller/mock hands back a string (e.g. an untyped test double, or a future
|
|
// driver change that stops honoring the cast).
|
|
const classification = classificationRes.rows[0] ?? null;
|
|
|
|
const remediationRes = await postgresClient.query<RemediationRow>(
|
|
`SELECT action_type, status, approved_by, approved_at::text AS approved_at
|
|
FROM remediation_actions WHERE campaign_id = $1 ORDER BY created_at ASC`,
|
|
[campaignId]
|
|
);
|
|
|
|
// Real indicator-URL join (Phase 16 evidence) — reports.evidence has NO url
|
|
// field, so urls must come from here, not from the reports.evidence JSONB.
|
|
const urlIndicatorsRes = await postgresClient.query<IndicatorUrlRow>(
|
|
`SELECT i.value FROM indicators i
|
|
JOIN messages m ON m.id = i.message_id
|
|
JOIN reports r ON r.id = m.report_id
|
|
WHERE r.campaign_id = $1 AND i.indicator_type = 'url'`,
|
|
[campaignId]
|
|
);
|
|
const urls = urlIndicatorsRes.rows.map((row) => row.value);
|
|
|
|
const primaryReport = reports[0] ?? null;
|
|
let blastRadius: BlastRadiusResult;
|
|
if (primaryReport) {
|
|
const createdAt = new Date(primaryReport.created_at);
|
|
// Best-available sender/recipient given only what the bounded reports
|
|
// query above returns (title only) — getBlastRadius never throws on
|
|
// sparse input, it degrades to `status: 'unavailable'`/empty counts.
|
|
blastRadius = await getBlastRadius({
|
|
sender: '',
|
|
recipient: '',
|
|
subject: primaryReport.title ?? '',
|
|
dateWindow: {
|
|
start: new Date(createdAt.getTime() - 24 * 60 * 60 * 1000),
|
|
end: new Date(createdAt.getTime() + 24 * 60 * 60 * 1000),
|
|
},
|
|
});
|
|
} else {
|
|
blastRadius = { status: 'unavailable', reason: 'not_configured' };
|
|
}
|
|
|
|
const confidence = classification?.confidence == null ? null : Number(classification.confidence);
|
|
|
|
const evidence: TriageNoteEvidence = {
|
|
campaignId,
|
|
reportCount: reports.length,
|
|
companyName: primaryReport?.company_name ?? null,
|
|
subject: primaryReport?.title ?? null,
|
|
verdict: (classification?.verdict as TriageNoteEvidence['verdict']) ?? null,
|
|
confidence,
|
|
summary: classification?.summary ?? null,
|
|
reasons: parseJsonArray(classification?.reasons),
|
|
recommendedActions: parseJsonArray(classification?.recommended_actions),
|
|
requiresApproval: classification?.requires_approval ?? false,
|
|
blastRadius,
|
|
remediationActions: remediationRes.rows.map((row) => ({
|
|
actionType: row.action_type,
|
|
status: row.status,
|
|
approvedBy: row.approved_by,
|
|
approvedAt: row.approved_at,
|
|
})),
|
|
urls,
|
|
};
|
|
|
|
const noteText = formatTriageNote(evidence);
|
|
|
|
const client = getAutotaskClient();
|
|
const tickets: TriageNotePostResult[] = [];
|
|
for (const report of reports) {
|
|
// Per-ticket try/catch is INSIDE the loop (not around it) so one
|
|
// ticket's write failure never aborts the remaining writes (D-05).
|
|
try {
|
|
await client.createEntity('TicketNotes', {
|
|
ticketID: Number(report.ticket_id),
|
|
title: 'Phishing Triage Summary',
|
|
description: noteText,
|
|
noteType: 1, // Internal
|
|
publish: 1,
|
|
});
|
|
tickets.push({ ticketId: report.ticket_id, posted: true });
|
|
} catch (err) {
|
|
console.error('[PHISHING-TRIAGE-NOTE] Failed to post note to ticket', report.ticket_id, err);
|
|
tickets.push({
|
|
ticketId: report.ticket_id,
|
|
posted: false,
|
|
error: err instanceof Error ? err.message : 'Unknown error',
|
|
});
|
|
}
|
|
}
|
|
|
|
return { noteText, tickets };
|
|
}
|
|
|
|
/**
|
|
* Phase 23 D-02/D-03: posts a short, genuinely appreciative thank-you note
|
|
* to every ticket linked to a USER_AWARENESS campaign — the `acknowledge_user`
|
|
* delivery action. Unlike `generateAndPostTriageNote` (an internal evidence
|
|
* dump), this note is customer-visible: `noteType: 18` ("Client Portal
|
|
* Note", verified live against this tenant's TicketNotes field metadata)
|
|
* with `publish: 1` left unchanged (an internal-staff-tier field, orthogonal
|
|
* to client visibility — see triage-note-format.ts / 23-PATTERNS.md watch-out
|
|
* flag #1). The body is a FIXED template with zero evidence/URL/classification
|
|
* interpolation (T-23-01) — nothing from the parsed email or classification
|
|
* reasons is ever placed in this note.
|
|
*
|
|
* Mirrors `generateAndPostTriageNote`'s per-ticket try/catch-in-loop error
|
|
* isolation (D-05) and `{ noteText, tickets }` return shape.
|
|
*/
|
|
export async function generateAndPostAcknowledgment(campaignId: string): Promise<TriageNoteResult> {
|
|
const reportsRes = await postgresClient.query<Pick<ReportRow, 'id' | 'ticket_id'>>(
|
|
`SELECT id::text, ticket_id::text AS ticket_id
|
|
FROM reports WHERE campaign_id = $1 ORDER BY created_at ASC`,
|
|
[campaignId]
|
|
);
|
|
const reports = reportsRes.rows;
|
|
|
|
const noteText = [
|
|
'Thanks for reporting this one!',
|
|
'',
|
|
"Heads up — this was a simulated phishing test from our security awareness program, not a real threat. But your instinct to flag it was spot on, and that's exactly what keeps us protected when the real thing shows up.",
|
|
'',
|
|
'Nicely done.',
|
|
].join('\n');
|
|
|
|
const client = getAutotaskClient();
|
|
const tickets: TriageNotePostResult[] = [];
|
|
for (const report of reports) {
|
|
// Per-ticket try/catch is INSIDE the loop (not around it) so one
|
|
// ticket's write failure never aborts the remaining writes (D-05).
|
|
try {
|
|
await client.createEntity('TicketNotes', {
|
|
ticketID: Number(report.ticket_id),
|
|
title: 'Thank You — Suspicious Email Reported',
|
|
description: noteText,
|
|
noteType: 18, // Client Portal Note — customer-visible (D-03)
|
|
publish: 1,
|
|
});
|
|
tickets.push({ ticketId: report.ticket_id, posted: true });
|
|
} catch (err) {
|
|
console.error('[PHISHING-ACKNOWLEDGMENT] Failed to post note to ticket', report.ticket_id, err);
|
|
tickets.push({
|
|
ticketId: report.ticket_id,
|
|
posted: false,
|
|
error: err instanceof Error ? err.message : 'Unknown error',
|
|
});
|
|
}
|
|
}
|
|
|
|
return { noteText, tickets };
|
|
}
|
|
|
|
/**
|
|
* Quick task 260717-v6c: posts a short, fixed-template customer-visible note
|
|
* to every ticket linked to a campaign that a reviewer has marked as an
|
|
* accidental report — an employee flagged a legitimate email by mistake.
|
|
* Mirrors `generateAndPostAcknowledgment`'s structure exactly: same reports
|
|
* lookup query, same per-ticket try/catch INSIDE the loop (D-05 isolation),
|
|
* same noteType 18 ("Client Portal Note") / publish 1, same
|
|
* `{ noteText, tickets }` return shape. The body is a FIXED template with
|
|
* zero evidence/URL/classification interpolation (T-23-01 invariant) —
|
|
* nothing from the parsed email or classification reasons is ever placed in
|
|
* this note.
|
|
*/
|
|
export async function generateAndPostAccidentalReportNote(campaignId: string): Promise<TriageNoteResult> {
|
|
const reportsRes = await postgresClient.query<Pick<ReportRow, 'id' | 'ticket_id'>>(
|
|
`SELECT id::text, ticket_id::text AS ticket_id
|
|
FROM reports WHERE campaign_id = $1 ORDER BY created_at ASC`,
|
|
[campaignId]
|
|
);
|
|
const reports = reportsRes.rows;
|
|
|
|
const noteText = [
|
|
'Thanks for flagging this — after review, this turned out to be a legitimate email that was reported by mistake, not a phishing attempt.',
|
|
'',
|
|
"No action is needed on your part, and this report has been closed out. If anything ever looks off in the future, please keep reporting it — that's exactly the right move.",
|
|
].join('\n\n');
|
|
|
|
const client = getAutotaskClient();
|
|
const tickets: TriageNotePostResult[] = [];
|
|
for (const report of reports) {
|
|
// Per-ticket try/catch is INSIDE the loop (not around it) so one
|
|
// ticket's write failure never aborts the remaining writes (D-05).
|
|
try {
|
|
await client.createEntity('TicketNotes', {
|
|
ticketID: Number(report.ticket_id),
|
|
title: 'Thank You — Report Reviewed',
|
|
description: noteText,
|
|
noteType: 18, // Client Portal Note — customer-visible
|
|
publish: 1,
|
|
});
|
|
tickets.push({ ticketId: report.ticket_id, posted: true });
|
|
} catch (err) {
|
|
console.error('[PHISHING-ACCIDENTAL-REPORT] Failed to post note to ticket', report.ticket_id, err);
|
|
tickets.push({
|
|
ticketId: report.ticket_id,
|
|
posted: false,
|
|
error: err instanceof Error ? err.message : 'Unknown error',
|
|
});
|
|
}
|
|
}
|
|
|
|
return { noteText, tickets };
|
|
}
|