wulf-pulse/.planning/phases/19-classification-engine/19-02-PLAN.md

9.2 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
19-classification-engine 02 execute 2
19-01
app/api/phishing/campaigns/[id]/classify/route.ts
true
CLASSIFY-05
truths artifacts key_links
POST /api/phishing/campaigns/{id}/classify calls requirePermission('phishing','analyze') and rejects unauthenticated (401) / unauthorized (403) requests
A malformed (non-UUID) campaign id returns 400 before any DB query
A valid authorized request runs classifyCampaign and returns the verdict payload (id, campaignId, verdict, confidence, summary, reasons, recommendedActions, requiresApproval)
An unknown campaign id returns 404
path provides exports min_lines
app/api/phishing/campaigns/[id]/classify/route.ts POST classify route handler
POST
30
from to via pattern
app/api/phishing/campaigns/[id]/classify/route.ts requirePermission('phishing','analyze') auth-utils early-return requirePermission('phishing', 'analyze')
from to via pattern
app/api/phishing/campaigns/[id]/classify/route.ts classifyCampaign import from @/lib/services/campaign-classifier classifyCampaign
Add the `POST /api/phishing/campaigns/{id}/classify` route — the on-demand (re-)trigger for campaign classification (D-02). It enforces the Phase 18 auth convention (`requirePermission('phishing','analyze')`, the SAME action as `/analyze`, not a new permission), validates the campaign id as a UUID (V5), delegates to `classifyCampaign(id)` from Plan 01, and returns the flat camelCase verdict payload.

Purpose: An operator can (re-)classify a campaign through a properly access-controlled endpoint. Output: app/api/phishing/campaigns/[id]/classify/route.ts.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/19-classification-engine/19-CONTEXT.md @.planning/phases/19-classification-engine/19-PATTERNS.md @.planning/phases/19-classification-engine/19-VALIDATION.md From lib/services/campaign-classifier.ts: ```typescript export interface ClassifyResult { id: string; campaignId: string; verdict: 'SPAM' | 'UNWANTED' | 'THREAT'; confidence: number; summary: string; reasons: string[]; recommendedActions: string[]; requiresApproval: boolean; createdAt: string; } export function classifyCampaign(campaignId: string): Promise; ```

Auth (lib/permissions.ts): phishing: ["read","analyze","approve","remediate"]; analyze is granted to super-admin + admin only (a plain user role has read only → expect 403).

UUID guard (from app/api/phishing/campaigns/[id]/route.ts):

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
Task 1: POST /api/phishing/campaigns/[id]/classify route handler app/api/phishing/campaigns/[id]/classify/route.ts - app/api/phishing/tickets/[ticket_id]/analyze/route.ts (exact auth early-return: `const { error } = await requirePermission('phishing','analyze'); if (error) return error;`; try/catch → 500 with message; [PHISHING-ANALYZE] log-prefix convention) - app/api/phishing/campaigns/[id]/route.ts (UUID_RE guard + 400; params is Promise<{ id: string }>; flat camelCase response shape; [PHISHING-CAMPAIGN-DETAIL] prefix; 404 when campaign not found) - lib/permissions.ts (confirm phishing 'analyze' action already exists — do NOT add a new action) - lib/services/campaign-classifier.ts (classifyCampaign signature + ClassifyResult from Plan 01) - .planning/phases/19-classification-engine/19-PATTERNS.md (route/controller section — verbatim analog mapping) Create `app/api/phishing/campaigns/[id]/classify/route.ts` exporting `async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> })`.
Body, in order:
1. `const { error } = await requirePermission('phishing', 'analyze'); if (error) return error;` — same `'analyze'` action as the existing `/analyze` route (Phase 18 D-06; T-19-04 access-control). Import `requirePermission` from `@/lib/auth-utils`.
2. `const { id } = await params;` then the `UUID_RE` guard copied verbatim from `campaigns/[id]/route.ts` — return `NextResponse.json({ error: 'Invalid campaign id' }, { status: 400 })` on failure, BEFORE any DB access (V5 / T-19-05 tampering guard). Use `campaigns/[id]/route.ts`'s UUID regex, NOT `analyze/route.ts`'s numeric `Number.isFinite` check (this id is a campaign UUID, not a ticket_id).
3. `try { ... } catch (err) { console.error('[PHISHING-CLASSIFY] Failed to classify campaign', id, err); return NextResponse.json({ error: 'Failed to classify campaign', message: err instanceof Error ? err.message : 'Unknown error' }, { status: 500 }); }`.
4. Inside try: verify the campaign exists first — `SELECT id FROM campaigns WHERE id = $1` (postgresClient default import from `@/lib/services/postgres-client`); if no row, return `NextResponse.json({ error: 'Campaign not found' }, { status: 404 })` (mirrors campaigns/[id]/route.ts 404). Then `const result = await classifyCampaign(id);` (import from `@/lib/services/campaign-classifier`) and `return NextResponse.json(result);` — `ClassifyResult` is already flat camelCase, no re-mapping needed.

Use path-alias `@/lib/...` imports (route-handler convention), NOT the sibling-filename imports used inside lib/services. Do not add the route to middleware public paths — `/api/phishing/*` is authenticated (this is intentional, T-19-04).
npx tsc --noEmit --pretty test -f "app/api/phishing/campaigns/[id]/classify/route.ts" && grep -q "requirePermission('phishing', 'analyze')" "app/api/phishing/campaigns/[id]/classify/route.ts" && echo AUTH_OK Against a dev server (npm run dev, port 3100) with a known campaign UUID: 1. `curl -X POST http://localhost:3100/api/phishing/campaigns/{uuid}/classify` with a valid admin/super-admin session cookie → 200 with verdict/confidence/summary/reasons/recommendedActions/requiresApproval 2. Same with NO session cookie → 401 3. Same with a plain `user`-role session cookie → 403 (analyze not granted to user) 4. `curl -X POST http://localhost:3100/api/phishing/campaigns/not-a-uuid/classify` (authed) → 400 5. Valid UUID that is not a real campaign → 404 - `npx tsc --noEmit --pretty` exits 0 - `grep -c "requirePermission('phishing', 'analyze')" "app/api/phishing/campaigns/[id]/classify/route.ts"` === 1 (T-19-04) - `grep -q "UUID_RE" "app/api/phishing/campaigns/[id]/classify/route.ts"` — UUID guard present before DB access (T-19-05) - `grep -q "classifyCampaign" "app/api/phishing/campaigns/[id]/classify/route.ts"` — delegates to the Plan 01 service - Manual curl (human-check): 200 for authed admin, 401 no-session, 403 user-role, 400 malformed id, 404 unknown campaign Route compiles, enforces analyze permission + UUID validation, delegates to classifyCampaign, and returns the verdict payload; manual auth curl matrix passes.

<threat_model>

Trust Boundaries

Boundary Description
client → API route Unauthenticated/unauthorized HTTP request + attacker-controlled id path param crosses into the handler

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-19-04 Elevation of Privilege POST /classify handler mitigate requirePermission('phishing','analyze') early-return before any work; analyze granted only to admin/super-admin (V4 access control, server-side not just middleware cookie check)
T-19-05 Tampering id path param mitigate UUID_RE shape validation returns 400 before any query, preventing a malformed id from surfacing as an uncaught Postgres 500 (V5 input validation)
T-19-SC Tampering npm/pip/cargo installs accept No new packages installed this phase; Package Legitimacy Gate N/A per 19-RESEARCH.md
</threat_model>
- `npx tsc --noEmit --pretty` — clean (route-handler tests are not this repo's convention; vitest.config does not scan app/**) - Manual curl auth matrix (401 / 403 / 400 / 404 / 200) per 19-VALIDATION.md Manual-Only Verifications - Route follows the ACCESS-01 carry-forward convention established in Phase 18

<success_criteria>

  • POST /api/phishing/campaigns/{id}/classify enforces requirePermission('phishing','analyze'), validates UUID, delegates to classifyCampaign, returns the verdict payload; rejects unauthenticated/unauthorized/malformed requests (CLASSIFY-05) </success_criteria>
Create `.planning/phases/19-classification-engine/19-02-SUMMARY.md` when done