From 1a126078d7c1dde0dfb8c502ee6d1b53a26ef29e Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 10:44:30 -0400 Subject: [PATCH] feat(20-02): add mark-false-positive route and classify audit event - POST /mark-false-positive: phishing:approve gated (D-04 elevated tier), optional reason body, delegates to markCampaignFalsePositive, maps RemediationConflictError->409 (already remediated) and RemediationValidationError->400 - classify route now writes a 'campaign_classified' audit event after a successful classification, completing REMED-06's four-action audit coverage (classify/approve/remediate/mark-false-positive) --- .../phishing/campaigns/[id]/classify/route.ts | 17 ++++- .../[id]/mark-false-positive/route.ts | 75 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 app/api/phishing/campaigns/[id]/mark-false-positive/route.ts diff --git a/app/api/phishing/campaigns/[id]/classify/route.ts b/app/api/phishing/campaigns/[id]/classify/route.ts index 74f5db2..eb61967 100644 --- a/app/api/phishing/campaigns/[id]/classify/route.ts +++ b/app/api/phishing/campaigns/[id]/classify/route.ts @@ -13,6 +13,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requirePermission } from '@/lib/auth-utils'; import postgresClient from '@/lib/services/postgres-client'; import { classifyCampaign } from '@/lib/services/campaign-classifier'; +import { writeAuditEvent } from '@/lib/services/phishing-audit'; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -20,7 +21,7 @@ export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - const { error } = await requirePermission('phishing', 'analyze'); + const { session, error } = await requirePermission('phishing', 'analyze'); if (error) return error; const { id } = await params; @@ -40,6 +41,20 @@ export async function POST( } const result = await classifyCampaign(id); + + // REMED-06: classify is the fourth state-changing action requiring audit + // coverage (alongside approve/remediate/mark-false-positive). The + // classification row has already been persisted above (append-only) — + // an audit-write failure surfaces as this route's 500, but the + // classification itself is not rolled back. + const actor = (session?.user as { email?: string } | undefined)?.email ?? null; + await writeAuditEvent({ + campaignId: id, + actor, + eventType: 'campaign_classified', + payload: { verdict: result.verdict, requiresApproval: result.requiresApproval }, + }); + return NextResponse.json(result); } catch (err) { console.error('[PHISHING-CLASSIFY] Failed to classify campaign', id, err); diff --git a/app/api/phishing/campaigns/[id]/mark-false-positive/route.ts b/app/api/phishing/campaigns/[id]/mark-false-positive/route.ts new file mode 100644 index 0000000..33ade1e --- /dev/null +++ b/app/api/phishing/campaigns/[id]/mark-false-positive/route.ts @@ -0,0 +1,75 @@ +/** + * POST /api/phishing/campaigns/[id]/mark-false-positive + * + * Marks a campaign as a false positive. Gated by phishing/approve (D-04 — + * same elevated tier as approve; no separate action key). Validates the + * campaign id as a UUID (V5), optionally accepts a JSON body with a `reason` + * string, and delegates to `markCampaignFalsePositive` (Plan 01), which + * guards against marking a campaign that already has approved/completed + * remediation (RemediationConflictError -> 409). + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import { + markCampaignFalsePositive, + RemediationValidationError, + RemediationConflictError, +} from '@/lib/services/remediation-service'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { session, error } = await requirePermission('phishing', 'approve'); + if (error) return error; + + const { id } = await params; + // V5: validate UUID shape before querying — a malformed id would otherwise + // surface as an unhandled Postgres error -> uncaught 500. + if (!UUID_RE.test(id)) { + return NextResponse.json({ error: 'Invalid campaign id' }, { status: 400 }); + } + + // Body is optional — tolerate an empty/absent body (default reason undefined). + let reason: string | undefined; + const rawBody = await request.text(); + if (rawBody.trim().length > 0) { + try { + const parsed = JSON.parse(rawBody) as { reason?: unknown }; + reason = typeof parsed.reason === 'string' ? parsed.reason : undefined; + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + } + + const actor = (session?.user as { email?: string } | undefined)?.email ?? null; + + try { + const campaignRes = await postgresClient.query<{ id: string }>( + `SELECT id FROM campaigns WHERE id = $1`, + [id] + ); + if (!campaignRes.rows[0]) { + return NextResponse.json({ error: 'Campaign not found' }, { status: 404 }); + } + + const result = await markCampaignFalsePositive(id, actor, reason); + return NextResponse.json(result); + } catch (err) { + if (err instanceof RemediationConflictError) { + return NextResponse.json({ error: err.message }, { status: 409 }); + } + if (err instanceof RemediationValidationError) { + return NextResponse.json({ error: err.message }, { status: 400 }); + } + console.error('[PHISHING-MARK-FP] Failed to mark campaign false positive', id, err); + return NextResponse.json( + { error: 'Failed to mark campaign false positive', message: err instanceof Error ? err.message : 'Unknown error' }, + { status: 500 } + ); + } +}