- 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
484 lines
17 KiB
TypeScript
484 lines
17 KiB
TypeScript
/**
|
|
* 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<string, unknown>;
|
|
}
|
|
|
|
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<ApprovedRemediationAction[]> {
|
|
return postgresClient.transaction(async (client) => {
|
|
const classificationRes = await client.query<ClassificationRow>(
|
|
`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<InsertRemediationRow>(
|
|
`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<RemediateResult> {
|
|
const result = await postgresClient.transaction(async (client) => {
|
|
const rowsRes = await client.query<RemediationActionRow>(
|
|
`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<MarkFalsePositiveResult> {
|
|
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<CampaignStatusRow>(
|
|
`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<MarkAccidentalReportResult> {
|
|
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<CampaignStatusRow>(
|
|
`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<AutoPostAcknowledgmentResult> {
|
|
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<ExistingAckActionRow>(
|
|
`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 };
|
|
}
|