/** * Remediation Service (Phase 20) * * Approve / remediate / mark-false-positive orchestrators for phishing * campaigns. Every state change here is proposed-only until an operator * explicitly approves it (REMED-01) — no function in this file auto-creates * an approved/completed remediation_actions row. Approval only materializes * action types that appear in the campaign's latest classification's * recommended_actions (REMED-02). Every state change writes exactly one * audit_events row, atomically, via writeAuditEvent(client) inside the same * postgresClient.transaction as the state write (REMED-06). * * D-01: this file never has an unimplemented/no-op code path for the * remediate step. Its "external effect" is a simulated internal transition * (status='approved' -> 'completed') — no real provider call for any of the * 7 original action types. The only explicit-failure branch is the * zero-approved-actions case (REMED-03) — remediating with nothing approved * always throws, never silently no-ops as a success. * * Phase 23 carve-out (D-04): the ONE exception is the post-commit * `acknowledge_user` customer-note post below — a non-destructive thank-you * message, not a security action. It runs AFTER the transaction commits * (never inside it — the Autotask write is network I/O and must not hold a * DB transaction open or risk a post-then-rollback), and its failure is * caught/logged, never propagated (the DB transition already succeeded). * Every other action type remains a simulated status-only transition. */ import { postgresClient } from './postgres-client'; import { writeAuditEvent } from './phishing-audit'; import { generateAndPostAcknowledgment, generateAndPostAccidentalReportNote } from './triage-note-service'; export class RemediationValidationError extends Error { constructor(message: string) { super(message); this.name = 'RemediationValidationError'; } } export class RemediationConflictError extends Error { constructor(message: string) { super(message); this.name = 'RemediationConflictError'; } } // ============================================================================= // approveRemediationActions (REMED-01, REMED-02, REMED-06) // ============================================================================= export interface ApproveActionInput { actionType: string; params?: Record; } export interface ApprovedRemediationAction { id: string; campaignId: string; actionType: string; status: 'approved'; approvedBy: string | null; } interface ClassificationRow { recommended_actions: string[] | string | null; } interface InsertRemediationRow { id: string; } /** Normalizes the JSONB recommended_actions column into a string[] regardless of driver JSON parsing. */ function parseRecommendedActions(value: string[] | string | null): 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 []; } /** * Validates each requested action against the campaign's latest * classification's recommended_actions, then inserts one approved * remediation_actions row per action plus one atomic audit row — all inside * a single transaction. */ export async function approveRemediationActions( campaignId: string, actions: ApproveActionInput[], actor: string | null ): Promise { return postgresClient.transaction(async (client) => { const classificationRes = await client.query( `SELECT recommended_actions FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC LIMIT 1`, [campaignId] ); const classification = classificationRes.rows[0]; if (!classification) { throw new RemediationValidationError('Campaign has no classification to approve against'); } const recommendedActions = new Set(parseRecommendedActions(classification.recommended_actions)); const requestsAcknowledgeUser = actions.some((action) => action.actionType === 'acknowledge_user'); let acknowledgeUserAlreadyPosted = false; if (requestsAcknowledgeUser) { const existingAckRes = await client.query<{ id: string }>( `SELECT id FROM remediation_actions WHERE campaign_id = $1 AND action_type = 'acknowledge_user' LIMIT 1`, [campaignId] ); acknowledgeUserAlreadyPosted = existingAckRes.rows.length > 0; } for (const action of actions) { if (!recommendedActions.has(action.actionType)) { throw new RemediationValidationError( `Action ${action.actionType} is not a recommended action for this campaign` ); } if (action.actionType === 'acknowledge_user' && acknowledgeUserAlreadyPosted) { throw new RemediationValidationError( 'acknowledge_user has already been posted for this campaign' ); } } const approved: ApprovedRemediationAction[] = []; const actionIds: string[] = []; for (const action of actions) { const insertRes = await client.query( `INSERT INTO remediation_actions (campaign_id, action_type, status, params, approved_by, approved_at) VALUES ($1, $2, 'approved', $3::jsonb, $4, NOW()) RETURNING id::text AS id`, [campaignId, action.actionType, JSON.stringify(action.params ?? {}), actor] ); const id = insertRes.rows[0].id; actionIds.push(id); approved.push({ id, campaignId, actionType: action.actionType, status: 'approved', approvedBy: actor, }); } await writeAuditEvent( { campaignId, actor, eventType: 'remediation_approved', payload: { actions, actionIds } }, client ); return approved; }); } // ============================================================================= // remediateApprovedActions (REMED-03, REMED-04, REMED-06) // ============================================================================= interface RemediationActionRow { id: string; action_type: string; status: string; } export interface RemediateResultAction { id: string; actionType: string; status: 'completed'; alreadyCompleted: boolean; } export interface RemediateResult { campaignId: string; actions: RemediateResultAction[]; } /** * Transitions every status='approved' row for the campaign to 'completed' * (the D-01 simulated internal effect — no real external provider call for * any action type) and writes one 'remediation_completed' audit row per * transitioned action. Rows already 'completed' are left untouched and * generate NO audit row — the status='approved' filter + FOR UPDATE is the * idempotency mechanism: a re-run finds no approved rows and transitions/ * audits nothing (REMED-04). A campaign with zero remediation_actions rows * throws explicitly (REMED-03) — this function never returns a silent * success for "nothing to do". */ export async function remediateApprovedActions(campaignId: string, actor: string | null): Promise { const result = await postgresClient.transaction(async (client) => { const rowsRes = await client.query( `SELECT id::text, action_type, status FROM remediation_actions WHERE campaign_id = $1 FOR UPDATE`, [campaignId] ); if (rowsRes.rows.length === 0) { throw new RemediationValidationError('No remediation actions to remediate — nothing approved'); } const actions: RemediateResultAction[] = []; for (const row of rowsRes.rows) { if (row.status === 'approved') { await client.query(`UPDATE remediation_actions SET status = 'completed' WHERE id = $1`, [row.id]); await writeAuditEvent( { campaignId, actor, eventType: 'remediation_completed', payload: { actionId: row.id, actionType: row.action_type }, }, client ); actions.push({ id: row.id, actionType: row.action_type, status: 'completed', alreadyCompleted: false }); } else { // Already-completed (or otherwise non-approved) rows are left // untouched and generate no audit row — idempotency (REMED-04). actions.push({ id: row.id, actionType: row.action_type, status: 'completed', alreadyCompleted: true }); } } return { campaignId, actions }; }); // Phase 23 D-04 carve-out: post the real customer-visible acknowledgment // note AFTER the transaction has committed — only when an approved // acknowledge_user row was actually transitioned this pass (never on the // already-completed idempotent re-run). Never held inside the DB // transaction (network I/O), and never allowed to propagate (the DB // transition already succeeded; generateAndPostAcknowledgment already // isolates per-ticket failures internally). const shouldPostAcknowledgment = result.actions.some( (action) => action.actionType === 'acknowledge_user' && action.alreadyCompleted === false ); if (shouldPostAcknowledgment) { try { await generateAndPostAcknowledgment(campaignId); } catch (err) { console.error('[REMEDIATE] acknowledge_user note post failed', campaignId, err); } } return result; } // ============================================================================= // markCampaignFalsePositive (D-04 guard, REMED-05, REMED-06) // ============================================================================= interface CampaignStatusRow { status: string; } export interface MarkFalsePositiveResult { campaignId: string; status: 'false_positive'; auditEventId: string; } /** * D-04 guard: rejects with RemediationConflictError when any * approved/completed remediation exists for the campaign — a campaign can * never be both remediated and false-positive. Otherwise sets * campaigns.status='false_positive' and writes one atomic audit row * recording the previous status and the optional reason. False-positive * reversibility is out of scope (CONTEXT.md Deferred Ideas) — no un-mark * path exists. */ export async function markCampaignFalsePositive( campaignId: string, actor: string | null, reason?: string ): Promise { return postgresClient.transaction(async (client) => { const guardRes = await client.query<{ id: string }>( `SELECT id FROM remediation_actions WHERE campaign_id = $1 AND status IN ('approved', 'completed') FOR UPDATE LIMIT 1`, [campaignId] ); if (guardRes.rows.length > 0) { throw new RemediationConflictError( 'Cannot mark false positive: campaign already has approved or completed remediation' ); } const campaignRes = await client.query( `SELECT status FROM campaigns WHERE id = $1`, [campaignId] ); const campaign = campaignRes.rows[0]; if (!campaign) { throw new RemediationValidationError('Campaign not found'); } const previousStatus = campaign.status; await client.query( `UPDATE campaigns SET status = 'false_positive', updated_at = NOW() WHERE id = $1`, [campaignId] ); const auditEventId = await writeAuditEvent( { campaignId, actor, eventType: 'campaign_marked_false_positive', payload: { previousStatus, reason: reason ?? null }, }, client ); return { campaignId, status: 'false_positive', auditEventId }; }); } // ============================================================================= // markCampaignAccidentalReport (D-04 guard, quick task 260717-v6c) // ============================================================================= export interface MarkAccidentalReportResult { campaignId: string; status: 'accidental_report'; auditEventId: string; notePosted: boolean; noteError?: string; } /** * D-04 guard: rejects with RemediationConflictError when any * approved/completed remediation exists for the campaign — mirrors * markCampaignFalsePositive exactly. Otherwise sets * campaigns.status='accidental_report' and writes one atomic audit row * recording the previous status and the optional reason. AFTER the * transaction commits, posts a fixed-template customer-visible note to every * reporting employee's ticket (generateAndPostAccidentalReportNote) — this * external Autotask call runs outside the FOR UPDATE-locked transaction and * its failure is caught/logged, never propagated: the committed status * change is returned regardless, with notePosted/noteError reflecting the * note outcome. */ export async function markCampaignAccidentalReport( campaignId: string, actor: string | null, reason?: string ): Promise { const result = await postgresClient.transaction(async (client) => { const guardRes = await client.query<{ id: string }>( `SELECT id FROM remediation_actions WHERE campaign_id = $1 AND status IN ('approved', 'completed') FOR UPDATE LIMIT 1`, [campaignId] ); if (guardRes.rows.length > 0) { throw new RemediationConflictError( 'Cannot mark accidental report: campaign already has approved or completed remediation' ); } const campaignRes = await client.query( `SELECT status FROM campaigns WHERE id = $1`, [campaignId] ); const campaign = campaignRes.rows[0]; if (!campaign) { throw new RemediationValidationError('Campaign not found'); } const previousStatus = campaign.status; await client.query( `UPDATE campaigns SET status = 'accidental_report', updated_at = NOW() WHERE id = $1`, [campaignId] ); const auditEventId = await writeAuditEvent( { campaignId, actor, eventType: 'campaign_marked_accidental_report', payload: { previousStatus, reason: reason ?? null }, }, client ); return { campaignId, status: 'accidental_report' as const, auditEventId }; }); let notePosted = false; let noteError: string | undefined; try { await generateAndPostAccidentalReportNote(campaignId); notePosted = true; } catch (err) { console.error('[MARK-ACCIDENTAL-REPORT] accidental-report note post failed', campaignId, err); noteError = err instanceof Error ? err.message : 'Unknown error'; } return { ...result, notePosted, noteError }; } // ============================================================================= // autoPostAcknowledgment (AUTOGATE-03 gap closure — CR-01 / WR-01) // ============================================================================= export interface AutoPostAcknowledgmentResult { posted: boolean; } interface ExistingAckActionRow { id: string; } /** * Idempotent, audit-persisting auto-post orchestrator for the auto_report * webhook path (runGatedPhishingStages in webhook-service.ts). Mirrors * remediateApprovedActions's shape: state write + audit row inside one * transaction, customer-visible note post AFTER commit, note-post failure * caught/logged and never propagated (the DB transition already committed). * * Unlike the manual path, this function performs the FIRST write of the * campaign's acknowledge_user row: the persisted remediation_actions row it * inserts is BOTH the idempotency record a repeat ticket-create webhook for * the same campaign reads on its next pass AND the row the Action Area / * Timeline UI already render from (closes WR-01, the root cause of CR-01 — * repeat webhooks were re-posting the customer-visible note on every * additional report joining an already-acknowledged campaign). * * The `SELECT ... FROM campaigns WHERE id = $1 FOR UPDATE` row lock * serializes concurrent webhooks for the same campaign so two simultaneous * ticket-create events cannot both pass the existence check and double-post. */ export async function autoPostAcknowledgment( campaignId: string, actor: string | null ): Promise { const { inserted } = await postgresClient.transaction(async (client) => { await client.query(`SELECT id FROM campaigns WHERE id = $1 FOR UPDATE`, [campaignId]); const existingRes = await client.query( `SELECT id FROM remediation_actions WHERE campaign_id = $1 AND action_type = 'acknowledge_user' LIMIT 1`, [campaignId] ); if (existingRes.rows.length > 0) { // Already posted for this campaign — no insert, no audit, no note. return { inserted: false }; } const insertRes = await client.query<{ id: string }>( `INSERT INTO remediation_actions (campaign_id, action_type, status, approved_by, approved_at) VALUES ($1, 'acknowledge_user', 'completed', $2, NOW()) RETURNING id::text AS id`, [campaignId, actor] ); await writeAuditEvent( { campaignId, actor, eventType: 'remediation_completed', payload: { actionId: insertRes.rows[0].id, actionType: 'acknowledge_user', auto: true }, }, client ); return { inserted: true }; }); if (inserted) { try { await generateAndPostAcknowledgment(campaignId); } catch (err) { console.error('[AUTO-REMEDIATE] acknowledge_user note post failed', campaignId, err); } } return { posted: inserted }; }