From b4707ce9620de0101450901e396910e0456a4163 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 13:46:09 -0400 Subject: [PATCH] docs(22): create phase plan (6 plans, 3 waves) --- .planning/ROADMAP.md | 10 +- .../22-01-PLAN.md | 191 +++++++++++++++++ .../22-02-PLAN.md | 193 ++++++++++++++++++ .../22-03-PLAN.md | 150 ++++++++++++++ .../22-04-PLAN.md | 146 +++++++++++++ .../22-05-PLAN.md | 158 ++++++++++++++ .../22-06-PLAN.md | 159 +++++++++++++++ .../22-VALIDATION.md | 34 +-- 8 files changed, 1024 insertions(+), 17 deletions(-) create mode 100644 .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-01-PLAN.md create mode 100644 .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-02-PLAN.md create mode 100644 .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-03-PLAN.md create mode 100644 .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-04-PLAN.md create mode 100644 .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-05-PLAN.md create mode 100644 .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-06-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 5a497cc..c0ad56e 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -503,7 +503,7 @@ Phases execute in numeric order. v1.0 (Phases 1-9.1) shipped 2026-07-10. v2.0 (P | 19. Classification Engine | v3.0 | 2/2 | Complete | 2026-07-16 | | 20. Remediation, Approval & Audit Safety | v3.0 | 2/2 | Complete | 2026-07-16 | | 21. Autotask Triage Note | v3.0 | 2/2 | Complete | 2026-07-16 | -| 22. Approval UI (LiveLink) | v3.0 | 0/TBD | Not started | - | +| 22. Approval UI (LiveLink) | v3.0 | 0/6 | Not started | - | ### Phase 22: Approval UI (LiveLink) **Goal**: A security operator opens an Autotask ticket, clicks a LiveLink button, and lands on a Pulse page scoped to that ticket showing the campaign's timeline, evidence, and classification — with approve/remediate/mark-false-positive actions right there, so no one is calling the Phase 20 APIs by hand. @@ -516,7 +516,13 @@ Phases execute in numeric order. v1.0 (Phases 1-9.1) shipped 2026-07-10. v2.0 (P 4. The page shows the current classification (SPAM/UNWANTED/THREAT), confidence, reasons, and recommended remediation action(s) from Phase 19 5. Approve, remediate, and mark-false-positive buttons call the Phase 20 APIs directly from the page and reflect the resulting state (e.g. a remediated campaign shows as remediated, not re-offered for approval) 6. An operator without the elevated permission REMED-02/ACCESS-01 already require sees the approve/remediate actions disabled or hidden rather than a failed request; the page never uses a relaxed or separate permission check from the underlying APIs -**Plans**: TBD +**Plans**: 6 plans +- [ ] 22-01-PLAN.md — Pure testable logic: ticket->campaign resolver, 7-action default-params, timeline merge (REVIEW-01, REVIEW-02, REVIEW-04) +- [ ] 22-02-PLAN.md — Backend routes: new ticket->campaign resolver + extend campaign-detail (evidence/timeline/classification/blast radius) + list firstReportTicketId (REVIEW-01..04) +- [ ] 22-03-PLAN.md — Evidence display: shadcn tooltip + inert UrlList (D-09) + tabbed EvidenceCard (REVIEW-03) +- [ ] 22-04-PLAN.md — ClassificationCard + TimelineCard (REVIEW-02, REVIEW-04) +- [ ] 22-05-PLAN.md — ActionAreaCard: approve/remediate/mark-false-positive with server-identical permission gating (REVIEW-05, REVIEW-06) +- [ ] 22-06-PLAN.md — Review page + campaigns list page + nav entry (REVIEW-01, REVIEW-05, REVIEW-06) **UI hint**: yes --- diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-01-PLAN.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-01-PLAN.md new file mode 100644 index 0000000..f5d182f --- /dev/null +++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-01-PLAN.md @@ -0,0 +1,191 @@ +--- +phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - lib/services/phishing-ticket-resolver.ts + - lib/services/phishing-ticket-resolver.test.ts + - lib/services/remediation-default-params.ts + - lib/services/remediation-default-params.test.ts + - lib/services/phishing-timeline.ts + - lib/services/phishing-timeline.test.ts +autonomous: true +requirements: [REVIEW-01, REVIEW-02, REVIEW-04] +must_haves: + truths: + - "Given a ticket id with no reports row, the resolver returns { found: false }" + - "Given a ticket id whose report has a null campaign_id, the resolver returns { found: true, campaignId: null }" + - "Given a ticket id whose report is grouped, the resolver returns { found: true, reportId, campaignId, ticketNumber }" + - "deriveDefaultParams returns the exact param object for each of the 7 action types" + - "mergeTimeline returns a single array of report/classification/audit entries sorted ascending by timestamp" + artifacts: + - path: "lib/services/phishing-ticket-resolver.ts" + provides: "resolveTicketToCampaign(ticketId) DB lookup" + exports: ["resolveTicketToCampaign", "TicketCampaignResolution"] + - path: "lib/services/remediation-default-params.ts" + provides: "7-action-type default-param derivation" + exports: ["deriveDefaultParams", "DefaultParamEvidence"] + - path: "lib/services/phishing-timeline.ts" + provides: "chronological merge of reports + classifications + audit events" + exports: ["mergeTimeline", "TimelineEntry"] + key_links: + - from: "lib/services/phishing-ticket-resolver.ts" + to: "reports table" + via: "postgresClient.query SELECT ... FROM reports WHERE ticket_id = $1" + pattern: "FROM reports WHERE ticket_id" +--- + + +Extract the three pieces of genuinely new pure logic in this phase into testable `lib/services/*.ts` files so they can be unit-tested under vitest (whose `test.include` is `lib/**/*.test.ts` only — `app/**` and `components/**` have no coverage). These are the phase's Wave 0 foundation: the ticket→campaign resolver (REVIEW-01), the 7-action-type default-param derivation (REVIEW-04), and the timeline merge/sort (REVIEW-02, server-merge approach chosen per RESEARCH.md Alternatives Considered). + +Purpose: Downstream route (plan 02) and component (plans 04/05) work consumes these; extracting them makes the phase's only new business logic verifiable rather than trapped inline in untestable route/component files. +Output: 3 service modules + 3 vitest test files, all green. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/ROADMAP.md +@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-RESEARCH.md +@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md +@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md + + +From lib/services/postgres-client.ts: + default export `postgresClient` (also named export). Use `postgresClient.query(sql, params)`. + +From lib/services/remediation-service.ts (the shape approve params must match): + export interface ApproveActionInput { actionType: string; params?: Record; } + +Confirmed 7-action vocabulary (lib/services/campaign-classifier.ts mapVerdictToActions): + no_action | warn_user | block_sender | purge_message | reset_password | isolate_endpoint | disable_forwarding_rule + + + + + + + Task 1: phishing-ticket-resolver.ts + test + lib/services/phishing-ticket-resolver.ts, lib/services/phishing-ticket-resolver.test.ts + + - lib/services/campaign-classifier.ts (top-level exported-async-function + typed-row + postgresClient idiom, and an existing vitest mock of postgres-client) + - lib/services/campaign-classifier.test.ts (how postgres-client is mocked with vi.mock) + - app/api/phishing/tickets/[ticket_id]/analyze/route.ts (the Number(ticket_id) param semantics — ticket_id is the numeric tickets.id / reports.ticket_id) + - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md (section "lib/services/phishing-ticket-resolver.ts") + + + - Given reports has no row for ticketId: resolveTicketToCampaign(ticketId) resolves to { found: false } + - Given a reports row with campaign_id = null: resolves to { found: true, reportId, campaignId: null, ticketNumber } + - Given a reports row with a campaign_id UUID: resolves to { found: true, reportId, campaignId: , ticketNumber } + + + Create `lib/services/phishing-ticket-resolver.ts` exporting `interface TicketCampaignResolution { found: boolean; reportId?: string; campaignId?: string | null; ticketNumber?: string | null; }` and `export async function resolveTicketToCampaign(ticketId: number): Promise`. It runs `postgresClient.query<{ id: string; campaign_id: string | null; ticket_number: string | null }>('SELECT id::text, campaign_id::text, ticket_number FROM reports WHERE ticket_id = $1', [ticketId])`, reads `res.rows[0]`; if absent returns `{ found: false }`; otherwise returns `{ found: true, reportId: row.id, campaignId: row.campaign_id, ticketNumber: row.ticket_number }`. Import postgresClient matching the sibling-file convention (default import from `./postgres-client`). Do NOT put requirePermission or param parsing here — that lives in the route (plan 02). Write `phishing-ticket-resolver.test.ts` that mocks `./postgres-client` via `vi.mock` (mirror campaign-classifier.test.ts) and asserts the three behaviors above by controlling the mocked query's returned rows. + + + npx vitest run lib/services/phishing-ticket-resolver.test.ts + + + - `npx vitest run lib/services/phishing-ticket-resolver.test.ts` passes with at least 3 assertions (no-report, ungrouped, grouped) + - `grep -q "FROM reports WHERE ticket_id = \$1" lib/services/phishing-ticket-resolver.ts` succeeds + - The file contains no `requirePermission` and no `NextResponse` import (pure service, route-free) + + resolveTicketToCampaign exported, test green, three resolution states covered. + + + + Task 2: remediation-default-params.ts + test + lib/services/remediation-default-params.ts, lib/services/remediation-default-params.test.ts + + - lib/services/campaign-classifier.ts (mapVerdictToActions — the exhaustive-switch shape to mirror) + - lib/services/remediation-service.ts lines 42-45 (ApproveActionInput shape the params feed into) + - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (section "Action Area Spec" → the 7-row default-params table) + - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md (section "lib/services/remediation-default-params.ts") + + + - deriveDefaultParams('no_action', ev) === {} + - deriveDefaultParams('warn_user', ev) === { recipientEmail: ev.requesterEmail ?? '', message: '' } + - deriveDefaultParams('block_sender', ev) === { senderEmail: ev.senderEmail ?? '', senderDomain: ev.senderDomain ?? '' } + - deriveDefaultParams('purge_message', ev) === { messageId: ev.messageId ?? '', mailboxes: [] } + - deriveDefaultParams('reset_password', ev) === { userPrincipalName: ev.requesterEmail ?? '' } + - deriveDefaultParams('isolate_endpoint', ev) === { deviceId: '' } + - deriveDefaultParams('disable_forwarding_rule', ev) === { userPrincipalName: ev.requesterEmail ?? '', ruleName: '' } + - deriveDefaultParams('unknown_future_type', ev) === {} + + + Create `lib/services/remediation-default-params.ts` exporting `interface DefaultParamEvidence { requesterEmail: string | null; senderEmail: string | null; senderDomain: string | null; messageId: string | null; }` and `export function deriveDefaultParams(actionType: string, evidence: DefaultParamEvidence): Record` implemented as a switch over the 7 action types exactly per the UI-SPEC Action Area default-params table (values listed in behavior above), with a `default: return {}` for any unknown/future action type. Pure function, no imports beyond types. Write `remediation-default-params.test.ts` asserting all 8 behaviors above (7 known types + 1 unknown fallback), using an evidence fixture with non-null values to prove pass-through and a second with all-null values to prove the `?? ''` / `?? ''` fallbacks. + + + npx vitest run lib/services/remediation-default-params.test.ts + + + - `npx vitest run lib/services/remediation-default-params.test.ts` passes with assertions for all 7 known action types plus the unknown-type fallback + - `grep -c "case '" lib/services/remediation-default-params.ts` returns at least 7 + - deriveDefaultParams is a pure function (no postgresClient / fetch / NextResponse import) + + All 7 action-type default-param shapes + unknown fallback verified by test. + + + + Task 3: phishing-timeline.ts + test (server-merge) + lib/services/phishing-timeline.ts, lib/services/phishing-timeline.test.ts + + - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (section "Timeline Spec" — the three sources and ascending-by-created_at ordering) + - app/api/phishing/campaigns/[id]/route.ts (the camelCase report/classification row shapes this merge consumes) + - lib/services/phishing-audit.ts (the 4 canonical audit_events event_type values) + + + - Given 2 reports, 1 classification, 2 audit events with interleaved timestamps, mergeTimeline returns a 5-element array sorted ascending by `at` + - Each entry carries a discriminant `kind` of 'report' | 'classification' | 'audit' and the source fields (report: reportId/ticketNumber/companyName; classification: verdict/confidence; audit: eventType/actor/payload) + - Ties on identical timestamps preserve a stable order (report, then classification, then audit) and do not throw + + + Create `lib/services/phishing-timeline.ts` exporting a discriminated union `type TimelineEntry` with three variants: `{ kind: 'report'; at: string; reportId: string; ticketNumber: string | null; companyName: string | null }`, `{ kind: 'classification'; at: string; verdict: string; confidence: string | null }`, `{ kind: 'audit'; at: string; eventType: string; actor: string | null; payload: unknown }`. Export `function mergeTimeline(reports: {...}[], classifications: {...}[], auditEvents: {...}[]): TimelineEntry[]` that maps each source into its variant (using each source's created_at as `at`), concatenates, and sorts ascending by `at` (compare via `Date(a.at).getTime() - Date(b.at).getTime()`; on equal times fall back to a fixed kind priority report + + npx vitest run lib/services/phishing-timeline.test.ts + + + - `npx vitest run lib/services/phishing-timeline.test.ts` passes, including an assertion that output timestamps are non-decreasing + - Output length equals sum of the three input array lengths + - `grep -q "kind: 'audit'" lib/services/phishing-timeline.ts` succeeds (all three variants present) + - mergeTimeline imports no postgresClient / NextResponse (pure) + + mergeTimeline produces a single ascending chronological array across all three sources; test green. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| route→service (plan 02 calls these) | These services receive an already-parsed `ticketId: number` and already-fetched row arrays; they perform no input parsing themselves | +| service→DB | resolveTicketToCampaign issues a parameterized query only | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-22-01 | Tampering (SQL injection) | phishing-ticket-resolver.ts | mitigate | Parameterized query `WHERE ticket_id = $1` with a `number` param — no string interpolation into SQL | +| T-22-02 | Information Disclosure | resolveTicketToCampaign | accept | Returns only reportId/campaignId/ticketNumber; access control is enforced in the route layer (plan 02), not here — this pure service is never exposed directly | +| T-22-SC | Tampering | npm/pip/cargo installs | accept | No package-manager installs in this plan; per 22-RESEARCH Package Legitimacy Audit the phase introduces zero new npm packages | + + + +- `npm test` (full suite) green +- `npx tsc --noEmit --pretty` clean + + + +Three pure-logic services exist with passing unit tests covering resolver (3 states), default-params (7 types + fallback), and timeline merge (chronological, 3 sources). + + + +Create `.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-01-SUMMARY.md` when done + diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-02-PLAN.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-02-PLAN.md new file mode 100644 index 0000000..359aea6 --- /dev/null +++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-02-PLAN.md @@ -0,0 +1,193 @@ +--- +phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve +plan: 02 +type: execute +wave: 2 +depends_on: ["22-01"] +files_modified: + - app/api/phishing/tickets/[ticket_id]/campaign/route.ts + - app/api/phishing/campaigns/[id]/route.ts + - app/api/phishing/campaigns/route.ts +autonomous: true +requirements: [REVIEW-01, REVIEW-02, REVIEW-03, REVIEW-04] +must_haves: + truths: + - "GET /api/phishing/tickets/{ticketId}/campaign returns { found:false } (200) when no report exists, { found:true, campaignId:null } for an ungrouped report, and { found:true, campaignId } for a grouped report" + - "GET /api/phishing/campaigns/{id} additionally returns remediationActions (with derived completedAt), auditEvents, full classification fields (reasons/recommendedActions/requiresApproval), full message evidence (headers/urls/attachments/bodyPreview), a fresh blastRadius, and a merged chronological timeline" + - "GET /api/phishing/campaigns additionally returns firstReportTicketId per campaign" + - "All three routes reject an unauthenticated/unauthorized caller via requirePermission('phishing','read')" + artifacts: + - path: "app/api/phishing/tickets/[ticket_id]/campaign/route.ts" + provides: "ticket→campaign resolver route" + exports: ["GET"] + - path: "app/api/phishing/campaigns/[id]/route.ts" + provides: "extended campaign-detail response for the review page" + contains: "blastRadius" + - path: "app/api/phishing/campaigns/route.ts" + provides: "campaigns list with firstReportTicketId for row navigation" + contains: "firstReportTicketId" + key_links: + - from: "app/api/phishing/tickets/[ticket_id]/campaign/route.ts" + to: "lib/services/phishing-ticket-resolver.ts" + via: "resolveTicketToCampaign(ticketId)" + pattern: "resolveTicketToCampaign" + - from: "app/api/phishing/campaigns/[id]/route.ts" + to: "lib/services/mimecast-blast-radius.ts" + via: "getBlastRadius fresh per request" + pattern: "getBlastRadius" + - from: "app/api/phishing/campaigns/[id]/route.ts" + to: "lib/services/phishing-timeline.ts" + via: "mergeTimeline(reports, classifications, auditEvents)" + pattern: "mergeTimeline" +--- + + +Provide the review page's read surface. Add one new thin resolver route (ticket ID → campaign) and additively extend the two existing campaign endpoints so the page has everything REVIEW-02/03/04 need — without breaking any existing consumer (grep confirms the detail route has no other consumer besides itself). + +Purpose: The Phase 20 write routes (approve/remediate/mark-false-positive) already exist and are reused verbatim; the only missing backend piece is the enriched read data. This plan supplies it. +Output: 1 new route + 2 additively-extended routes. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/ROADMAP.md +@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-RESEARCH.md +@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md + + +From lib/services/phishing-ticket-resolver.ts (plan 01): + export async function resolveTicketToCampaign(ticketId: number): Promise + export interface TicketCampaignResolution { found: boolean; reportId?: string; campaignId?: string | null; ticketNumber?: string | null; } + +From lib/services/phishing-timeline.ts (plan 01): + export function mergeTimeline(reports, classifications, auditEvents): TimelineEntry[] + +From lib/services/mimecast-blast-radius.ts (existing): + export async function getBlastRadius(input: BlastRadiusInput): Promise + BlastRadiusInput { messageId?: string; sender: string; recipient: string; subject: string; dateWindow: { start: Date; end: Date }; } + BlastRadiusResult = { status:'unavailable'; reason:'not_configured'|'lookup_failed'; error? } | { status:'ok'; matched; delivered; held; rejected; clicked; perRecipient: {recipient; status}[] } + +From lib/services/remediation-service.ts (existing, for reference — NOT modified here): + ApproveActionInput { actionType: string; params?: Record } + +Existing detail route messages query TODAY only selects id/report_id/message_id/subject — this plan widens it to include headers, urls, attachments, body_preview. + + + + + + + Task 1: NEW ticket→campaign resolver route + app/api/phishing/tickets/[ticket_id]/campaign/route.ts + + - app/api/phishing/tickets/[ticket_id]/analyze/route.ts (exact requirePermission + Number(ticket_id) validation + try/catch idiom to mirror) + - lib/services/phishing-ticket-resolver.ts (plan 01 — the function this route wraps) + - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md (section for this route) + + + Create `app/api/phishing/tickets/[ticket_id]/campaign/route.ts` exporting `GET(request, { params }: { params: Promise<{ ticket_id: string }> })`. First `const { error } = await requirePermission('phishing', 'read'); if (error) return error;`. Then `const { ticket_id } = await params; const ticketId = Number(ticket_id); if (!Number.isFinite(ticketId)) return NextResponse.json({ error: 'Invalid ticket_id' }, { status: 400 });`. Wrap in try/catch (copy the `console.error('[PHISHING-TICKET-CAMPAIGN] ...', ticketId, err)` + 500 shape from analyze/route.ts). In the try, call `resolveTicketToCampaign(ticketId)` and return it directly via `NextResponse.json(...)`. Note deliberate design (D-07): a missing report is `{ found: false }` at HTTP 200, NOT a 404 — the page distinguishes "valid ticket, not triaged yet" from a hard error via the `found` boolean. Use GET (pure read), not POST. Import postgresClient is NOT needed here (the service owns the query). + + + npx tsc --noEmit --pretty 2>&1 | grep -c "phishing/tickets/\[ticket_id\]/campaign" | grep -qx 0 && echo TYPECHECK-CLEAN + + + - `grep -q "requirePermission('phishing', 'read')" app/api/phishing/tickets/[ticket_id]/campaign/route.ts` succeeds + - `grep -q "resolveTicketToCampaign" app/api/phishing/tickets/[ticket_id]/campaign/route.ts` succeeds + - `grep -q "Number.isFinite" app/api/phishing/tickets/[ticket_id]/campaign/route.ts` succeeds + - `grep -q "export async function GET" app/api/phishing/tickets/[ticket_id]/campaign/route.ts` succeeds and no POST/PUT/PATCH/DELETE export exists + - `npx tsc --noEmit --pretty` reports no error in this file + + Resolver route returns found/reportId/campaignId/ticketNumber, auth-gated, param-validated. + + + + Task 2: EXTEND campaigns/[id] detail route (evidence + timeline + classification + blast radius) + app/api/phishing/campaigns/[id]/route.ts + + - app/api/phishing/campaigns/[id]/route.ts (the full existing file — extend, do not rewrite; keep every existing field/query untouched) + - lib/services/campaign-classifier.ts lines 320-360 (gatherCampaignEvidence — the CORRECT sender/recipient/subject/dateWindow derivation to copy for getBlastRadius; do NOT copy triage-note-service.ts's empty-string call — Pitfall 3) + - lib/services/phishing-eml-service.ts (the exact messages.headers/urls/attachments/body_preview persisted JSONB shapes) + - lib/services/phishing-audit.ts (audit_events columns: actor, event_type, payload; the remediation_completed payload.actionId used to derive completedAt) + - lib/services/phishing-timeline.ts (plan 01 — mergeTimeline) + - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md (section "app/api/phishing/campaigns/[id]/route.ts (EXTEND)") + + + Additively extend the existing GET handler. (1) Widen the existing `messagesRes` SELECT to also return `headers, urls, attachments, body_preview` (all real columns on `messages`; subject already comes from `headers->>'subject'`) and include them (camelCased: headers/urls/attachments/bodyPreview) in the existing `messages` map output. (2) Add a `remediationRes` query `SELECT id::text, action_type, status, params, approved_by, approved_at::text FROM remediation_actions WHERE campaign_id = $1 ORDER BY created_at ASC`. (3) Add an `auditRes` query `SELECT id::text, actor, event_type, payload, created_at::text FROM audit_events WHERE campaign_id = $1 ORDER BY created_at ASC`. (4) Widen the existing `classificationsRes` SELECT to add `reasons, recommended_actions, requires_approval` (real columns; keep `ORDER BY created_at DESC`) and include them (camelCased reasons/recommendedActions/requiresApproval) in the map. (5) Build `completedAtByActionId` in app code by iterating auditRes rows where `event_type === 'remediation_completed'` and reading `(payload as {actionId?:string}).actionId → created_at`; map `remediationActions` to camelCase including `completedAt: completedAtByActionId.get(a.id) ?? null` (there is NO completed_at column — Pitfall 2). (6) Derive `blastRadius` by copying campaign-classifier.ts's gatherCampaignEvidence logic exactly: take the earliest report (reports[0]), find its message, find the `sender`-type indicator, call `getBlastRadius({ sender: senderIndicator?.value ?? primaryMessage?.headers.from.email ?? '', recipient: primaryReport.requesterEmail ?? '', subject: primaryMessage?.subject ?? primaryReport.title ?? '', dateWindow: { start: createdAt - 24h, end: createdAt + 24h } })`; when there is no report use `{ status: 'unavailable', reason: 'not_configured' }`. Await it inside the existing try block. (7) Add `timeline: mergeTimeline(reports, classifications, auditEvents)` to the response using the already-mapped camelCase arrays. Add the four new response fields (`remediationActions`, `auditEvents`, `blastRadius`, `timeline`) and the widened `messages`/`classifications` shapes to the returned JSON. Do NOT alter the existing catch block, the UUID_RE guard, or any existing field name. Add imports for `getBlastRadius`/`BlastRadiusResult` from `@/lib/services/mimecast-blast-radius` and `mergeTimeline` from `@/lib/services/phishing-timeline`. + + + npx tsc --noEmit --pretty 2>&1 | grep -c "campaigns/\[id\]/route" | grep -qx 0 && echo TYPECHECK-CLEAN + + + - `grep -q "getBlastRadius" app/api/phishing/campaigns/[id]/route.ts` succeeds + - `grep -q "mergeTimeline" app/api/phishing/campaigns/[id]/route.ts` succeeds + - `grep -q "FROM remediation_actions WHERE campaign_id" app/api/phishing/campaigns/[id]/route.ts` succeeds + - `grep -q "FROM audit_events WHERE campaign_id" app/api/phishing/campaigns/[id]/route.ts` succeeds + - `grep -q "recommended_actions" app/api/phishing/campaigns/[id]/route.ts` and `grep -q "requires_approval" ...` both succeed + - `grep -v '^#' app/api/phishing/campaigns/[id]/route.ts | grep -c "completed_at"` returns 0 (no reference to the nonexistent column) + - The existing `requirePermission('phishing', 'read')` call and `UUID_RE` guard remain present + - `npx tsc --noEmit --pretty` reports no error in this file + + Detail route returns enriched evidence, timeline, classification, remediation actions with derived completedAt, and fresh blast radius — all additive. + + + + Task 3: EXTEND campaigns list route with firstReportTicketId + app/api/phishing/campaigns/route.ts + + - app/api/phishing/campaigns/route.ts (full existing file — additive change only) + - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Campaigns list page section — firstReportTicketId is the row-click navigation target) + + + Add one correlated subquery column to the existing campaigns SELECT: `(SELECT r.ticket_id::text FROM reports r WHERE r.campaign_id = c.id ORDER BY r.created_at ASC LIMIT 1) AS first_report_ticket_id`. Alias the campaigns table as `c` in that SELECT (currently unaliased) — apply the alias consistently to the existing selected columns and to the `${statusFilter}`/ORDER BY so nothing breaks. Add `firstReportTicketId: c.first_report_ticket_id` to the existing `items.map()`. Do NOT change the count query, the limit/offset logic, the requirePermission gate, or the response envelope shape (`{ items, total, limit, offset }`). Purely additive field. + + + npx tsc --noEmit --pretty 2>&1 | grep -c "campaigns/route" | grep -qx 0 && echo TYPECHECK-CLEAN + + + - `grep -q "first_report_ticket_id" app/api/phishing/campaigns/route.ts` succeeds + - `grep -q "firstReportTicketId" app/api/phishing/campaigns/route.ts` succeeds + - The response still returns `{ items, total, limit, offset }` (envelope unchanged) + - `npx tsc --noEmit --pretty` reports no error in this file + + List route returns firstReportTicketId per campaign for row-click navigation. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| browser→API | Operator supplies ticket_id (numeric) and campaign id (UUID) in the URL path — untrusted | +| API→DB | Parameterized queries only | +| API→Mimecast (server-side) | getBlastRadius runs server-side with server-only Mimecast creds; never called from the client | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-22-03 | Elevation of Privilege | all three routes | mitigate | Each route calls `requirePermission('phishing', 'read')` and returns its error before any query — identical to the 6 existing phishing routes | +| T-22-04 | Tampering (SQLi) | resolver + extended routes | mitigate | ticket_id parsed via `Number.isFinite`; campaign id gated by existing `UUID_RE`; all queries parameterized | +| T-22-05 | Information Disclosure (campaign-UUID enumeration) | campaigns/[id] | mitigate | requirePermission('phishing','read') gates the whole route before the DB query; a guessed UUID without read permission 401/403s first | +| T-22-06 | Information Disclosure (blast radius scoped to wrong sender/recipient) | campaigns/[id] blastRadius | mitigate | Copy campaign-classifier's real sender/recipient derivation, not triage-note-service's empty-string call (Pitfall 3) — prevents an unscoped Mimecast fan-out | +| T-22-SC | Tampering | npm/pip/cargo installs | accept | No package-manager installs in this plan (22-RESEARCH Package Legitimacy Audit — zero new packages) | + + + +- `npm test` green (plan 01 tests still pass; no test coverage for routes under vitest config — type-check is the safety net per CLAUDE.md) +- `npx tsc --noEmit --pretty` clean +- Manual: `curl` the resolver route for a known-triaged ticket returns `found:true` with a campaignId + + + +Review page can fetch (a) a ticket→campaign resolution and (b) an enriched campaign detail containing evidence, timeline, classification, remediation actions, and blast radius; list page can fetch campaigns with firstReportTicketId. All auth-gated, all additive. + + + +Create `.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-02-SUMMARY.md` when done + diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-03-PLAN.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-03-PLAN.md new file mode 100644 index 0000000..7d4c395 --- /dev/null +++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-03-PLAN.md @@ -0,0 +1,150 @@ +--- +phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve +plan: 03 +type: execute +wave: 1 +depends_on: [] +files_modified: + - components/ui/tooltip.tsx + - components/phishing/url-list.tsx + - components/phishing/evidence-card.tsx +autonomous: true +requirements: [REVIEW-03] +must_haves: + truths: + - "Extracted URLs render as inert monospace text with a copy-to-clipboard button — never as a clickable link" + - "The evidence card shows parsed Headers, URLs, Attachments, Body preview, and Blast Radius tabs for a selected message" + - "Body preview renders as plain text in a
, never via dangerouslySetInnerHTML"
+    - "Blast radius renders an explicit 'unavailable' state when status is unavailable, and counts + per-recipient table when ok"
+  artifacts:
+    - path: "components/ui/tooltip.tsx"
+      provides: "shadcn tooltip primitive (for later disabled-button explanations)"
+    - path: "components/phishing/url-list.tsx"
+      provides: "inert copy-only URL list (D-09)"
+      exports: ["UrlList"]
+    - path: "components/phishing/evidence-card.tsx"
+      provides: "tabbed EML evidence display (REVIEW-03)"
+      exports: ["EvidenceCard"]
+  key_links:
+    - from: "components/phishing/url-list.tsx"
+      to: "navigator.clipboard"
+      via: "writeText on copy button click"
+      pattern: "navigator.clipboard.writeText"
+---
+
+
+Build the read-only evidence surface for the review page: the shadcn `tooltip` primitive (needed later by the action card), the inert copy-only `UrlList` component enforcing D-09's stricter-than-sanitization posture, and the tabbed `EvidenceCard` rendering parsed EML headers, URLs, attachments, body preview, and Mimecast blast radius (REVIEW-03).
+
+Purpose: This is the phase's biggest XSS-exposure surface (attacker-controlled email content). It is built as a pure presentational component so the review page (plan 06) composes it without embedding rendering logic.
+Output: 1 shadcn primitive + 2 phishing components.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md
+@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md
+
+
+messages evidence shape (from the extended detail route, plan 02):
+  headers: { from:{displayName,email,domain}; replyTo; returnPath; to:string[]; cc:string[]; subject; date; messageId; receivedChain:string[]; authResults:{spf?;dkim?;dmarc?}; authResultsOriginal:{...}|null }
+  urls: string[]
+  attachments: Array<{ filename:string|null; contentType:string|null; size:number; checksum:string|null; related:boolean }>
+  bodyPreview: string (plain text, ≤500 chars)
+
+blastRadius (from detail route):
+  { status:'unavailable'; reason:'not_configured'|'lookup_failed'; error? }
+  | { status:'ok'; matched; delivered; held; rejected; clicked; perRecipient: Array<{recipient; status:'delivered'|'held'|'rejected'|'unknown'}> }
+
+Reused primitives (do not modify): components/ui/{tabs,table,accordion,alert,card,select,status-badge}.tsx, sonner toast, lucide-react (Copy).
+
+
+
+
+
+
+  Task 1: Add tooltip primitive + build UrlList (D-09 inert)
+  components/ui/tooltip.tsx, components/phishing/url-list.tsx
+  
+    - components/ui/status-badge.tsx (existing primitive prop shape + cn() styling convention)
+    - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Evidence Display Spec §2 — exact url-list markup + copy affordance)
+    - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md (section "components/phishing/url-list.tsx")
+  
+  
+    First run `npx shadcn add tooltip` (official registry, generates `components/ui/tooltip.tsx` — no npm dependency added; if the CLI prompts, accept defaults). Then create `components/phishing/url-list.tsx` as a `'use client'` component exporting `UrlList({ urls }: { urls: string[] })`. Render each URL as `{url}` followed by an icon-only copy button (`Button size="icon" variant="ghost"`, lucide `Copy` at `h-3.5 w-3.5`, `aria-label="Copy URL"`) whose onClick calls `navigator.clipboard.writeText(url)` then `toast.success('Copied')`. CRITICAL (D-09): never wrap a URL in ``/`href`, never attach a navigating onClick, never use `` — copy-to-clipboard only. When `urls.length === 0`, render inline text "No URLs found in this message." (size sm, muted), NOT the full EmptyState component.
+  
+  
+    test -f components/ui/tooltip.tsx && grep -q "navigator.clipboard.writeText" components/phishing/url-list.tsx && grep -Lq "href" components/phishing/url-list.tsx && echo OK
+  
+  
+    - `components/ui/tooltip.tsx` exists and exports Tooltip/TooltipTrigger/TooltipContent/TooltipProvider
+    - `grep -q "navigator.clipboard.writeText" components/phishing/url-list.tsx` succeeds
+    - `grep -c "href" components/phishing/url-list.tsx` returns 0 (no href anywhere)
+    - `grep -q "aria-label=\"Copy URL\"" components/phishing/url-list.tsx` succeeds
+    - No new dependency added to package.json (git diff of package.json is empty)
+  
+  Tooltip primitive present; UrlList renders inert copy-only URLs with zero clickable link surface.
+
+
+
+  Task 2: EvidenceCard — tabbed EML evidence (REVIEW-03)
+  components/phishing/evidence-card.tsx
+  
+    - components/admin/DetailModal.tsx (card + tabs formatted/raw precedent)
+    - components/ui/status-badge.tsx (StatusBadge prop shapes for SPF/DKIM/DMARC + blast-radius status badges)
+    - components/phishing/url-list.tsx (task 1 — consumed here)
+    - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Evidence Display Spec §1-5, Color table for badge recipes)
+    - .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md (section for classification/evidence/timeline components)
+  
+  
+    Create `components/phishing/evidence-card.tsx` (`'use client'`) exporting `EvidenceCard({ messages, blastRadius })` where `messages` is the extended detail route's message array (each with headers/urls/attachments/bodyPreview + ticketNumber-bearing report linkage) and `blastRadius` is the BlastRadiusResult. Wrap in a shadcn `` with `` (explicit font-bold override — CardTitle defaults to font-semibold) and a leading lucide `FileText` icon (`h-4 w-4 mr-2 inline`). If more than one message exists, render an outer `