wulf-pulse/lib/services/remediation-service.ts
lorentz 6224e44219 feat(23-01): wire acknowledge_user manual-path real note post into remediateApprovedActions
- remediateApprovedActions now captures the transaction's RemediateResult,
  then post-commit checks whether an approved acknowledge_user row was
  transitioned this pass (alreadyCompleted === false) and, if so, calls
  generateAndPostAcknowledgment(campaignId) exactly once
- Call happens outside the DB transaction (network I/O hazard) and is
  wrapped in its own try/catch that logs and swallows failures -- the DB
  transition has already committed
- Every other action type (block_sender, purge_message, warn_user,
  reset_password, isolate_endpoint, disable_forwarding_rule, quarantine)
  remains a simulated status-only transition, unchanged
- Updated top-of-file D-01 doc comment to record the narrow D-04 carve-out
- Tests: acknowledge_user IS posted once when remediated, NOT called for
  block_sender/warn_user-only remediation, NOT called on idempotent re-run
  of an already-completed acknowledge_user row, and a post rejection does
  not propagate out of remediateApprovedActions
2026-07-16 19:39:13 -04:00

307 lines
11 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 } 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));
for (const action of actions) {
if (!recommendedActions.has(action.actionType)) {
throw new RemediationValidationError(
`Action ${action.actionType} is not a recommended action 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 };
});
}