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)
This commit is contained in:
parent
65c4253f98
commit
1a126078d7
2 changed files with 91 additions and 1 deletions
|
|
@ -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);
|
||||
|
|
|
|||
75
app/api/phishing/campaigns/[id]/mark-false-positive/route.ts
Normal file
75
app/api/phishing/campaigns/[id]/mark-false-positive/route.ts
Normal file
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue