docs(21): create phase plan
This commit is contained in:
parent
8d918f67bb
commit
b0a15f30f2
3 changed files with 407 additions and 2 deletions
|
|
@ -468,7 +468,9 @@ of a functional dependency on Phase 21.
|
|||
1. If Pulse has a safe existing Autotask note-writing method, triggering note generation for a classified campaign posts an internal triage note summarizing classification, evidence, blast radius, and recommended actions to the originating ticket
|
||||
2. The posted (or returned) note text is sanitized — no raw secrets/tokens/full malicious URL query strings appear in it
|
||||
3. If no safe note-writing path exists, the same note content is returned via the API response instead of attempting any Autotask write, and no partial/unsanitized write is ever attempted as a fallback
|
||||
**Plans**: TBD
|
||||
**Plans**: 2 plans
|
||||
- [ ] 21-01-PLAN.md — Pure text layer: triage-note-sanitize (URL query/secret stripping) + triage-note-format (TriageNoteEvidence + formatTriageNote) with Vitest coverage (NOTE-01)
|
||||
- [ ] 21-02-PLAN.md — triage-note-service (evidence gather + per-ticket TicketNotes post loop + partial-failure result) + POST /api/phishing/campaigns/[id]/triage-note route (NOTE-01)
|
||||
**UI hint**: no
|
||||
|
||||
## Progress
|
||||
|
|
@ -500,7 +502,7 @@ Phases execute in numeric order. v1.0 (Phases 1-9.1) shipped 2026-07-10. v2.0 (P
|
|||
| 18. Campaign Grouping & Phishing Analysis API | v3.0 | 5/5 | Complete | 2026-07-16 |
|
||||
| 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 | 0/TBD | Not started | - |
|
||||
| 21. Autotask Triage Note | v3.0 | 0/2 | Planned | - |
|
||||
| 22. Approval UI (LiveLink) | v3.0 | 0/TBD | Not started | - |
|
||||
|
||||
### Phase 22: Approval UI (LiveLink)
|
||||
|
|
|
|||
186
.planning/phases/21-autotask-triage-note/21-01-PLAN.md
Normal file
186
.planning/phases/21-autotask-triage-note/21-01-PLAN.md
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
---
|
||||
phase: 21-autotask-triage-note
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- lib/services/triage-note-sanitize.ts
|
||||
- lib/services/triage-note-sanitize.test.ts
|
||||
- lib/services/triage-note-format.ts
|
||||
- lib/services/triage-note-format.test.ts
|
||||
autonomous: true
|
||||
requirements: [NOTE-01]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A pure sanitizer strips query strings and fragments from every URL and redacts token/secret markers before any text can reach a note"
|
||||
- "A pure formatter turns a structured campaign-evidence object into human-readable prose containing verdict, confidence, summary, reasons, blast-radius state, and current remediation state"
|
||||
- "The formatted note never contains a raw URL query string, Bearer token, or credential query-param value even when the source evidence does"
|
||||
artifacts:
|
||||
- path: "lib/services/triage-note-sanitize.ts"
|
||||
provides: "sanitizeUrl + sanitizeNoteText pure functions, exported for tests"
|
||||
contains: "export function sanitizeUrl"
|
||||
- path: "lib/services/triage-note-format.ts"
|
||||
provides: "TriageNoteEvidence interface + formatTriageNote(evidence) => string"
|
||||
contains: "export interface TriageNoteEvidence"
|
||||
- path: "lib/services/triage-note-sanitize.test.ts"
|
||||
provides: "Vitest coverage of URL-query stripping + secret redaction"
|
||||
- path: "lib/services/triage-note-format.test.ts"
|
||||
provides: "Vitest coverage of note sections + no-raw-secret invariant"
|
||||
key_links:
|
||||
- from: "lib/services/triage-note-format.ts"
|
||||
to: "lib/services/triage-note-sanitize.ts"
|
||||
via: "import sanitizeUrl/sanitizeNoteText"
|
||||
pattern: "from './triage-note-sanitize'"
|
||||
- from: "lib/services/triage-note-format.ts"
|
||||
to: "lib/services/mimecast-blast-radius.ts"
|
||||
via: "import type BlastRadiusResult"
|
||||
pattern: "BlastRadiusResult"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the pure, deterministic text layer for the Autotask triage note: a sanitizer that removes malicious/secret content from any string destined for a note, and a formatter that renders a structured campaign-evidence object into human-readable prose. Both are pure functions with no DB, network, or Autotask dependency — fully unit-testable in isolation.
|
||||
|
||||
Purpose: NOTE-01 requires the note text be sanitized (no raw secrets/tokens/full malicious URL query strings) and human-readable. Isolating this logic as pure functions lets the security-critical sanitization be tested exhaustively and lets Plan 02's service simply gather evidence and post the produced text. Defining `TriageNoteEvidence` here also gives Plan 02 a concrete contract to build against (interface-first).
|
||||
|
||||
Output: `lib/services/triage-note-sanitize.ts`, `lib/services/triage-note-format.ts`, and their sibling test files.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/21-autotask-triage-note/21-CONTEXT.md
|
||||
@.planning/phases/21-autotask-triage-note/21-PATTERNS.md
|
||||
|
||||
<interfaces>
|
||||
<!-- BlastRadiusResult (import as `type` from lib/services/mimecast-blast-radius.ts) -->
|
||||
<!-- Discriminated union the formatter must render, both branches: -->
|
||||
<!-- { status: 'unavailable'; reason: 'not_configured' | 'lookup_failed'; error?: string } -->
|
||||
<!-- | { status: 'ok'; matched: number; delivered: number; held: number; rejected: number; clicked: number; -->
|
||||
<!-- perRecipient: Array<{ recipient: string; status: 'delivered'|'held'|'rejected'|'unknown' }>; source: 'fan-out' } -->
|
||||
|
||||
<!-- Redaction spirit-reference (do NOT import — different technique, redacts by object key): -->
|
||||
<!-- lib/services/analyzer/itglue-redact.ts exports redact()/isSensitiveKey(); SENSITIVE_KEY_PATTERN = -->
|
||||
<!-- /password|secret|key|token|credential|api[_-]?key/i; REDACTED_VALUE = '[REDACTED]'. -->
|
||||
<!-- Copy the module-level "SECURITY-CRITICAL / why it exists" comment convention and the -->
|
||||
<!-- exported-pure-function-for-tests convention; do NOT copy the object-tree walk. -->
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Sanitizer (URL query stripping + secret redaction)</name>
|
||||
<files>lib/services/triage-note-sanitize.ts, lib/services/triage-note-sanitize.test.ts</files>
|
||||
<read_first>
|
||||
- lib/services/analyzer/itglue-redact.ts (module comment convention + exported-pure-function-for-tests shape to mirror; do NOT reuse its by-key redaction technique)
|
||||
- lib/services/remediation-service.test.ts (Vitest file structure/idioms used across lib/services)
|
||||
- .planning/phases/21-autotask-triage-note/21-CONTEXT.md (Claude's Discretion block: URL truncated to scheme+host+path, query stripped; sender emails + attachment hashes are evidence, NOT secrets — must be preserved)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- sanitizeUrl('https://evil.example/login?token=abc123&next=/x#frag') returns 'https://evil.example/login' (scheme+host+path only; query AND fragment removed)
|
||||
- sanitizeUrl on a malformed/non-URL string (e.g. 'not a url', '') does not throw and returns a safe placeholder or the input with any '?...'/'#...' tail removed
|
||||
- sanitizeUrl preserves the path but never the query even for benign params (query is always stripped, not selectively)
|
||||
- sanitizeNoteText redacts an Authorization/Bearer token: input containing 'Bearer eyJabc.def.ghi' yields '[REDACTED]' in place of the token
|
||||
- sanitizeNoteText redacts credential-style query-param values inside free text: 'see http://x/y?access_token=SECRET&password=p' has no substring 'access_token=SECRET' and no substring 'password=p' in the output
|
||||
- sanitizeNoteText PRESERVES a bare sender email (e.g. 'attacker@evil.example') and a 64-char hex attachment hash unchanged (these are evidence per D — not treated as secrets)
|
||||
- Both functions are pure (no I/O), deterministic, and exported for direct import in tests
|
||||
</behavior>
|
||||
<action>
|
||||
Create `lib/services/triage-note-sanitize.ts` with a module-level SECURITY-CRITICAL comment (mirroring the "why it exists" convention at the top of `lib/services/analyzer/itglue-redact.ts`) stating plainly that this module exists so no raw secret, token, or full malicious URL query string ever reaches an Autotask note posted by Pulse.
|
||||
Export `sanitizeUrl(value: string): string` — parse with the WHATWG `URL` constructor inside a try/catch; on success return `url.origin + url.pathname` (drops search + hash); on parse failure, fall back to truncating at the first `?` or `#` and return the head, never throwing. Do not selectively keep any query params — strip the query wholesale (this satisfies NOTE-01's "full malicious URL query strings").
|
||||
Export `sanitizeNoteText(text: string): string` — apply, in order: (1) replace any `Bearer <token>` / `Authorization: <value>` sequence with `[REDACTED]`; (2) replace credential query-param assignments matching keys `token|access_token|refresh_token|secret|password|api[_-]?key|key|credential` (case-insensitive) `=<value>` with `<key>=[REDACTED]`; (3) find any full URL substring and replace it with its `sanitizeUrl()` form so query strings embedded in prose are also stripped. Use `REDACTED_MARKER = '[REDACTED]'` constant. Do NOT redact bare email addresses or hex hashes — reason in a comment that those are evidence, not credentials, per CONTEXT.md Claude's Discretion.
|
||||
Write `lib/services/triage-note-sanitize.test.ts` covering every bullet in <behavior>, following the Vitest `describe/it/expect` structure used in `lib/services/remediation-service.test.ts` (no mocks needed — pure functions).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run lib/services/triage-note-sanitize.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npx vitest run lib/services/triage-note-sanitize.test.ts` passes with tests for: URL query+fragment stripping, malformed-URL no-throw, Bearer redaction, credential query-param redaction, and email/hash preservation
|
||||
- Source assertion: `grep -q "export function sanitizeUrl" lib/services/triage-note-sanitize.ts` and `grep -q "export function sanitizeNoteText" lib/services/triage-note-sanitize.ts`
|
||||
- Behavior assertion: a test feeds 'https://evil.example/a?token=abc#f' to sanitizeUrl and asserts the result === 'https://evil.example/a'
|
||||
- Behavior assertion: a test asserts sanitizeNoteText output for input with 'access_token=SECRET' contains no substring 'access_token=SECRET'
|
||||
- `npx tsc --noEmit --pretty` reports no new errors in these files
|
||||
</acceptance_criteria>
|
||||
<done>Sanitizer functions exist, are pure and exported, and all sanitize tests pass; type-check clean.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Note formatter + TriageNoteEvidence contract</name>
|
||||
<files>lib/services/triage-note-format.ts, lib/services/triage-note-format.test.ts</files>
|
||||
<read_first>
|
||||
- lib/services/triage-note-sanitize.ts (the functions this formatter must route all URL/free-text through — created in Task 1)
|
||||
- lib/services/mimecast-blast-radius.ts (BlastRadiusResult discriminated union both branches — import as type only)
|
||||
- lib/services/campaign-classifier.ts (Verdict type, confidence/reasons/recommended_actions shape produced by Phase 19 that this note summarizes)
|
||||
- .planning/phases/21-autotask-triage-note/21-CONTEXT.md (D-04 full-picture content requirement; note text format is planner's/Claude's discretion but must read as human-readable prose, not a JSON dump)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- formatTriageNote returns a string containing the verdict label (e.g. 'THREAT') and the confidence value
|
||||
- Output contains a Summary line and a Reasons section listing each reason string
|
||||
- Output contains a Blast Radius section: when blastRadius.status==='ok' it shows delivered/held/rejected/clicked counts; when status==='unavailable' it contains an explicit 'unavailable' string naming the reason (never silently omits the section) — per D-04
|
||||
- Output contains a Remediation section: when remediationActions is empty it states no action has been taken / actions are proposed-only; when rows exist it lists each action_type + status (+ approver when approved) reflecting CURRENT state per D-04
|
||||
- Output lists any indicator URLs in sanitized form: given an evidence URL 'http://evil.example/p?token=leak', the output contains no substring 'token=leak'
|
||||
- The whole returned string is passed through sanitizeNoteText so no secret/token survives even from free-text fields
|
||||
- Exports `TriageNoteEvidence` interface consumed by Plan 02
|
||||
</behavior>
|
||||
<action>
|
||||
Create `lib/services/triage-note-format.ts`. Export an interface `TriageNoteEvidence` with these fields (camelCase, project convention): `campaignId: string`, `reportCount: number`, `companyName?: string | null`, `subject?: string | null`, `verdict: 'SPAM' | 'UNWANTED' | 'THREAT' | null`, `confidence: number | null`, `summary: string | null`, `reasons: string[]`, `recommendedActions: string[]`, `requiresApproval: boolean`, `blastRadius: BlastRadiusResult` (import the type from `./mimecast-blast-radius`), `remediationActions: Array<{ actionType: string; status: string; approvedBy: string | null; approvedAt: string | null }>`, `urls: string[]`.
|
||||
Export `formatTriageNote(evidence: TriageNoteEvidence): string` that assembles clearly-labeled prose sections in this order — a header line ('Phishing Triage Summary' + campaign/report count), Classification (verdict + confidence + summary), Reasons (bulleted list of `evidence.reasons`), Blast Radius (render both BlastRadiusResult branches: for 'ok' show matched/delivered/held/rejected/clicked; for 'unavailable' write an explicit line like 'Blast radius: unavailable (reason)'), Recommended Actions (list `recommendedActions`; note `requiresApproval` when true), and Current Remediation State (if `remediationActions` empty, state actions are proposed-only / none taken; else list actionType — status, plus approver/approvedAt when present). Route every URL through `sanitizeUrl` and pass the fully assembled string through `sanitizeNoteText` before returning, so query strings/secrets are stripped regardless of source field. Handle null verdict/confidence gracefully (render 'not yet classified') rather than throwing.
|
||||
Write `lib/services/triage-note-format.test.ts` covering every <behavior> bullet, constructing `TriageNoteEvidence` fixtures for: an 'ok' blast-radius THREAT campaign, an 'unavailable' blast-radius campaign, an empty-remediation campaign, an approved-remediation campaign, and a campaign whose evidence URL carries a token query param (assert it is absent from output). No mocks required — pure function.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run lib/services/triage-note-format.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npx vitest run lib/services/triage-note-format.test.ts` passes
|
||||
- Source assertion: `grep -q "export interface TriageNoteEvidence" lib/services/triage-note-format.ts` and `grep -q "export function formatTriageNote" lib/services/triage-note-format.ts`
|
||||
- Source assertion: `grep -q "triage-note-sanitize" lib/services/triage-note-format.ts` (formatter routes through the sanitizer)
|
||||
- Behavior assertion: a test with a 'unavailable' BlastRadiusResult asserts the output contains the substring 'unavailable'
|
||||
- Behavior assertion: a test with an evidence URL containing 'token=leak' asserts output has no substring 'token=leak'
|
||||
- `npx tsc --noEmit --pretty` reports no new errors
|
||||
</acceptance_criteria>
|
||||
<done>Formatter and TriageNoteEvidence contract exist, all format tests pass, output is sanitized and human-readable; type-check clean.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| campaign/message evidence → note text | Attacker-controlled email content (subject, sender display name, URLs, reasons derived from headers) flows into a string that will be written to Autotask |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-21-01 | Information Disclosure | sanitizeUrl / sanitizeNoteText | mitigate | Strip URL query+fragment wholesale (scheme+host+path only); redact Bearer/Authorization and credential query-param values before any text is returned. Proven by Task 1 tests asserting `token=`/`access_token=`/`password=` values and Bearer tokens are absent from output. |
|
||||
| T-21-02 | Injection | formatTriageNote output | mitigate | Note is plain-text (Autotask TicketNotes description is not HTML-rendered by Pulse); formatter emits prose, never executable markup. Evidence values are only ever concatenated as text, not evaluated. |
|
||||
| T-21-03 | Information Disclosure | formatTriageNote (missing evidence) | accept | When blast radius is 'unavailable' or classification is null, the formatter emits an explicit "unavailable"/"not yet classified" line rather than leaking internal error detail — bounded, operator-facing, no secret content. |
|
||||
| T-21-SC | Tampering | npm/pip/cargo installs | accept | No new package installs in this plan; RESEARCH disabled for project and no install tasks present. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npx vitest run lib/services/triage-note-sanitize.test.ts lib/services/triage-note-format.test.ts` — both suites pass
|
||||
- `npx tsc --noEmit --pretty` — no new type errors
|
||||
- `grep -rq "sanitizeNoteText\|sanitizeUrl" lib/services/triage-note-format.ts` — formatter uses the sanitizer
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Sanitizer strips URL query strings/fragments and redacts secrets/tokens, with email/hash evidence preserved (NOTE-01 sanitization foundation)
|
||||
- Formatter renders verdict, confidence, summary, reasons, blast-radius (both branches), and current remediation state as human-readable prose
|
||||
- `TriageNoteEvidence` is exported for Plan 02 to build against
|
||||
- All unit tests pass; type-check clean
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/21-autotask-triage-note/21-01-SUMMARY.md` when done
|
||||
</output>
|
||||
217
.planning/phases/21-autotask-triage-note/21-02-PLAN.md
Normal file
217
.planning/phases/21-autotask-triage-note/21-02-PLAN.md
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
---
|
||||
phase: 21-autotask-triage-note
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [21-01]
|
||||
files_modified:
|
||||
- lib/services/triage-note-service.ts
|
||||
- lib/services/triage-note-service.test.ts
|
||||
- app/api/phishing/campaigns/[id]/triage-note/route.ts
|
||||
autonomous: true
|
||||
requirements: [NOTE-01]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "POST /api/phishing/campaigns/{id}/triage-note requires the phishing:analyze permission and rejects unauthenticated/unauthorized callers with 401/403"
|
||||
- "For a classified campaign the endpoint posts an internal (non-portal) Autotask TicketNote to every ticket linked to the campaign"
|
||||
- "The API response always includes the generated sanitized note text plus a per-ticket posted/error status list"
|
||||
- "A single ticket's Autotask write failure is captured per-ticket and does not abort the whole call, and no unsanitized/partial write is attempted as a fallback"
|
||||
artifacts:
|
||||
- path: "lib/services/triage-note-service.ts"
|
||||
provides: "generateAndPostTriageNote(campaignId) orchestrator: evidence gather + note post loop + partial-failure result"
|
||||
contains: "export async function generateAndPostTriageNote"
|
||||
- path: "app/api/phishing/campaigns/[id]/triage-note/route.ts"
|
||||
provides: "POST route: permission gate + UUID guard + campaign-exists check + service delegation"
|
||||
exports: ["POST"]
|
||||
- path: "lib/services/triage-note-service.test.ts"
|
||||
provides: "Vitest coverage of per-ticket write loop + partial-failure + note text return"
|
||||
key_links:
|
||||
- from: "app/api/phishing/campaigns/[id]/triage-note/route.ts"
|
||||
to: "lib/services/triage-note-service.ts"
|
||||
via: "generateAndPostTriageNote(id)"
|
||||
pattern: "generateAndPostTriageNote"
|
||||
- from: "lib/services/triage-note-service.ts"
|
||||
to: "Autotask TicketNotes"
|
||||
via: "getAutotaskClient().createEntity('TicketNotes', ...)"
|
||||
pattern: "createEntity\\('TicketNotes'"
|
||||
- from: "lib/services/triage-note-service.ts"
|
||||
to: "lib/services/triage-note-format.ts"
|
||||
via: "formatTriageNote(evidence)"
|
||||
pattern: "formatTriageNote"
|
||||
- from: "app/api/phishing/campaigns/[id]/triage-note/route.ts"
|
||||
to: "lib/auth-utils.ts"
|
||||
via: "requirePermission('phishing', 'analyze')"
|
||||
pattern: "requirePermission\\('phishing', 'analyze'\\)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the triage-note service and its on-demand API route. The service gathers a campaign's current evidence (linked reports/tickets, most-recent classification, current remediation state, fresh blast radius), renders the sanitized note via Plan 01's formatter, and posts one internal Autotask TicketNote per linked ticket — capturing each write's success/failure independently. The route is a thin, access-controlled controller matching the existing `classify` route shape.
|
||||
|
||||
Purpose: Satisfies NOTE-01 — Pulse already has a safe Autotask note-write path (`createEntity('TicketNotes', ...)`), so this phase posts the note to every ticket linked to the campaign (D-01) via a new operator-triggered endpoint (D-02), always posting fresh state (D-03), including the full picture of classification + blast radius + current remediation state (D-04), and reporting per-ticket write outcomes so a failed write never aborts the call or gets silently swallowed (D-05/D-06).
|
||||
|
||||
Output: `lib/services/triage-note-service.ts`, its test, and `app/api/phishing/campaigns/[id]/triage-note/route.ts`.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/21-autotask-triage-note/21-CONTEXT.md
|
||||
@.planning/phases/21-autotask-triage-note/21-PATTERNS.md
|
||||
@.planning/phases/21-autotask-triage-note/21-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- FROM PLAN 01 (already built) -->
|
||||
<!-- lib/services/triage-note-format.ts: -->
|
||||
<!-- export interface TriageNoteEvidence { campaignId; reportCount; companyName?; subject?; -->
|
||||
<!-- verdict: 'SPAM'|'UNWANTED'|'THREAT'|null; confidence: number|null; summary: string|null; -->
|
||||
<!-- reasons: string[]; recommendedActions: string[]; requiresApproval: boolean; -->
|
||||
<!-- blastRadius: BlastRadiusResult; -->
|
||||
<!-- remediationActions: Array<{ actionType; status; approvedBy: string|null; approvedAt: string|null }>; -->
|
||||
<!-- urls: string[]; } -->
|
||||
<!-- export function formatTriageNote(evidence: TriageNoteEvidence): string -->
|
||||
|
||||
<!-- EXISTING CONTRACTS TO REUSE -->
|
||||
<!-- lib/services/postgres-client.ts: default export `postgresClient`; .query<T>(sql, params) -> { rows: T[] } -->
|
||||
<!-- lib/services/mimecast-blast-radius.ts: getBlastRadius(input: BlastRadiusInput): Promise<BlastRadiusResult> -->
|
||||
<!-- BlastRadiusInput keys: { sender, recipient, subject, dateWindow: { start: Date, end: Date } } (see campaign-classifier.ts usage) -->
|
||||
<!-- lib/services/autotask-factory.ts: getAutotaskClient() -> client with createEntity<T>(type, data) -->
|
||||
<!-- (throws Error('Failed to create entity') on no item; network/HTTP errors propagate) -->
|
||||
<!-- lib/types/autotask.ts: TicketNote { id; ticketID; title?; description?; noteType?; publish?; ... } -->
|
||||
|
||||
<!-- SCHEMA (read-only) migrations/097_phishing_triage_schema.sql -->
|
||||
<!-- reports(id, ticket_id BIGINT NOT NULL, ticket_number, company_id, company_name, requester_contact_id, -->
|
||||
<!-- title, description, evidence JSONB, campaign_id, created_at) UNIQUE(ticket_id) -->
|
||||
<!-- classifications(campaign_id, verdict, confidence, summary, reasons JSONB, recommended_actions JSONB, requires_approval, created_at) -->
|
||||
<!-- remediation_actions(campaign_id, action_type, status, params JSONB, approved_by, approved_at, created_at) -->
|
||||
<!-- campaigns(id, status, report_count) -->
|
||||
|
||||
<!-- ROUTE TWIN (copy structure verbatim): app/api/phishing/campaigns/[id]/classify/route.ts -->
|
||||
<!-- default import postgresClient; requirePermission('phishing','analyze'); UUID_RE guard; -->
|
||||
<!-- SELECT id FROM campaigns WHERE id=$1 -> 404; delegate; NextResponse.json(result); -->
|
||||
<!-- catch -> console.error('[PHISHING-...]') + 500 with { error, message } -->
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: triage-note-service (evidence gather + per-ticket post loop)</name>
|
||||
<files>lib/services/triage-note-service.ts, lib/services/triage-note-service.test.ts</files>
|
||||
<read_first>
|
||||
- lib/services/triage-note-format.ts (TriageNoteEvidence contract + formatTriageNote — from Plan 01)
|
||||
- lib/services/campaign-classifier.ts (evidence-gathering query shapes: reports WHERE campaign_id, getBlastRadius call inputs, classification/reasons handling)
|
||||
- lib/services/remediation-service.ts (most-recent-row ORDER BY created_at DESC LIMIT 1 idiom; remediation_actions column reads)
|
||||
- lib/services/workflow-engine.ts (runAiTroubleshooting ~lines 560-613: the createEntity('TicketNotes', {...}) write shape to copy — noteType:1 Internal, publish:1)
|
||||
- lib/services/remediation-service.test.ts (Vitest vi.mock('./postgres-client') + vi.fn query pattern to mirror; this codebase mocks postgres and dependencies rather than hitting a live DB)
|
||||
- lib/services/autotask-factory.ts (getAutotaskClient singleton to mock in the test)
|
||||
- .planning/phases/21-autotask-triage-note/21-CONTEXT.md (D-01 every linked ticket; D-04 current remediation state; D-05/D-06 per-ticket failure semantics + response shape)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- generateAndPostTriageNote(campaignId) resolves { noteText: string; tickets: Array<{ ticketId: string; posted: boolean; error?: string }> }
|
||||
- Given a campaign with 3 linked reports (3 ticket_ids), createEntity('TicketNotes', ...) is called exactly 3 times, once per ticketID (Number-coerced), with description === the generated noteText and noteType:1, publish:1
|
||||
- result.tickets has one entry per linked report; all posted:true when every write succeeds
|
||||
- When getAutotaskClient().createEntity rejects for exactly one of the 3 tickets, that ticket's entry is posted:false with a non-empty error string, the other two are posted:true, and the promise still resolves (the whole call is NOT aborted) — D-05
|
||||
- result.noteText is always present and non-empty regardless of any write outcome — D-06 (text never withheld)
|
||||
- noteText is produced by formatTriageNote(evidence) built from the most-recent classification row, current remediation_actions rows, and a getBlastRadius() result — so an evidence URL with a token query param is absent from noteText (sanitization flows through)
|
||||
- A campaign with zero linked reports resolves { noteText, tickets: [] } (still returns the note text; no writes attempted) rather than throwing
|
||||
- A campaign with no classification row still resolves (note renders 'not yet classified' via formatter) rather than throwing
|
||||
</behavior>
|
||||
<action>
|
||||
Create `lib/services/triage-note-service.ts` exporting `interface TriageNotePostResult { ticketId: string; posted: boolean; error?: string }`, `interface TriageNoteResult { noteText: string; tickets: TriageNotePostResult[] }`, and `async function generateAndPostTriageNote(campaignId: string): Promise<TriageNoteResult>`.
|
||||
Read side: import the default `postgresClient` from `./postgres-client`. Query all linked reports with `SELECT id::text, ticket_id::text AS ticket_id, ticket_number, title, company_name, requester_contact_id, evidence, created_at FROM reports WHERE campaign_id = $1 ORDER BY created_at ASC`. Query the most-recent classification with `SELECT verdict, confidence, summary, reasons, recommended_actions, requires_approval, created_at FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC LIMIT 1` (nullable — handle absence). Query current remediation state with `SELECT action_type, status, approved_by, approved_at::text AS approved_at FROM remediation_actions WHERE campaign_id = $1 ORDER BY created_at ASC`. Parse JSONB `reasons`/`recommended_actions` into `string[]` defensively (already-array vs JSON string). Derive candidate `urls` from report `evidence` if present.
|
||||
Blast radius: call `getBlastRadius({ sender, recipient, subject, dateWindow: { start, end } })` fresh (D-04 current truth) using best-available sender/recipient/subject derived from the primary report's evidence/title and a ±24h window around the primary report `created_at` — copy the input-shaping approach from `campaign-classifier.ts`. `getBlastRadius` never throws (returns `{ status: 'unavailable' }` when Mimecast is not configured), so no guard needed beyond passing what you have.
|
||||
Build a `TriageNoteEvidence` object (import the interface + `formatTriageNote` from `./triage-note-format`) mapping: campaignId, reportCount = reports.length, companyName/subject from the primary report, verdict/confidence/summary/reasons/recommendedActions/requiresApproval from the classification (nulls when absent), blastRadius, remediationActions (map action_type→actionType, approved_by→approvedBy, approved_at→approvedAt), urls. Call `formatTriageNote(evidence)` to get `noteText`.
|
||||
Write side: `const client = getAutotaskClient();` then loop the reports; for each, in its OWN try/catch, `await client.createEntity('TicketNotes', { ticketID: Number(report.ticket_id), title: 'Phishing Triage Summary', description: noteText, noteType: 1, publish: 1 })` and push `{ ticketId: report.ticket_id, posted: true }`; on catch, `console.error('[PHISHING-TRIAGE-NOTE] Failed to post note to ticket', report.ticket_id, err)` and push `{ ticketId, posted: false, error: err instanceof Error ? err.message : 'Unknown error' }`. The per-ticket try/catch MUST be inside the loop (not around it) so one failure never aborts remaining writes (D-05). Return `{ noteText, tickets }`.
|
||||
Write `lib/services/triage-note-service.test.ts` following `remediation-service.test.ts`: `vi.mock('./postgres-client', ...)` with a `queryMock` returning seeded reports/classification/remediation rows per SQL, `vi.mock('./autotask-factory', ...)` exposing a `createEntityMock`, and `vi.mock('./mimecast-blast-radius', ...)` returning a fixed BlastRadiusResult. Cover every <behavior> bullet, especially the one-ticket-fails-others-succeed case and the note-text-always-returned case.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run lib/services/triage-note-service.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npx vitest run lib/services/triage-note-service.test.ts` passes
|
||||
- Source assertion: `grep -q "export async function generateAndPostTriageNote" lib/services/triage-note-service.ts`
|
||||
- Source assertion: `grep -q "createEntity('TicketNotes'" lib/services/triage-note-service.ts` and `grep -q "noteType: 1" lib/services/triage-note-service.ts` and `grep -q "publish: 1" lib/services/triage-note-service.ts`
|
||||
- Source assertion: `grep -q "formatTriageNote" lib/services/triage-note-service.ts` (uses Plan 01 formatter)
|
||||
- Behavior assertion: a test mocks createEntity to reject on the 2nd of 3 tickets and asserts tickets[1].posted===false, tickets[0].posted===true, tickets[2].posted===true, and the returned promise resolves (call not aborted)
|
||||
- Behavior assertion: a test asserts result.noteText is a non-empty string even when a write fails
|
||||
- `npx tsc --noEmit --pretty` reports no new errors
|
||||
</acceptance_criteria>
|
||||
<done>Service gathers current campaign evidence, renders the sanitized note, posts one internal TicketNote per linked ticket with independent per-ticket error capture, and returns note text + per-ticket status; all tests pass; type-check clean.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: POST /api/phishing/campaigns/[id]/triage-note route</name>
|
||||
<files>app/api/phishing/campaigns/[id]/triage-note/route.ts</files>
|
||||
<read_first>
|
||||
- app/api/phishing/campaigns/[id]/classify/route.ts (exact structural twin to copy: imports, UUID_RE, permission gate, campaign-exists 404 check, delegation, catch/500 shape)
|
||||
- lib/services/triage-note-service.ts (generateAndPostTriageNote signature + TriageNoteResult return shape — from Task 1)
|
||||
- lib/auth-utils.ts (requirePermission early-return pattern)
|
||||
- lib/permissions.ts (confirms 'phishing'/'analyze' is already granted — no new permission statement needed)
|
||||
- .planning/phases/21-autotask-triage-note/21-CONTEXT.md (Claude's Discretion: use the 'analyze' tier like classify, not 'approve'; D-06 response returned verbatim)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `app/api/phishing/campaigns/[id]/triage-note/route.ts` by copying the structure of `app/api/phishing/campaigns/[id]/classify/route.ts`. Import `NextRequest, NextResponse` from 'next/server', `requirePermission` from '@/lib/auth-utils', default `postgresClient` from '@/lib/services/postgres-client', and `generateAndPostTriageNote` from '@/lib/services/triage-note-service'. Reuse the identical `UUID_RE` regex constant. Export `async function POST(request, { params }: { params: Promise<{ id: string }> })`.
|
||||
In the handler: call `const { session, error } = await requirePermission('phishing', 'analyze'); if (error) return error;` (D — informational action uses the same tier as classify, NOT 'approve'). Await `params`, validate `id` against `UUID_RE` returning 400 `{ error: 'Invalid campaign id' }` on mismatch. In a try block, run `SELECT id FROM campaigns WHERE id = $1` and return 404 `{ error: 'Campaign not found' }` if no row; otherwise `const result = await generateAndPostTriageNote(id);` and `return NextResponse.json(result);` (return the service result verbatim — note text + per-ticket status per D-06, no reshaping). In catch, `console.error('[PHISHING-TRIAGE-NOTE] Failed to generate triage note', id, err)` and return 500 `{ error: 'Failed to generate triage note', message: err instanceof Error ? err.message : 'Unknown error' }`. Do NOT add an audit-event write (CONTEXT.md: not required for this phase; sent-note history is a deferred idea). This outer catch only fires for whole-request failures — per-ticket write failures are already captured inside the service and returned in the 200 body (D-05).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Source assertion: `grep -q "requirePermission('phishing', 'analyze')" app/api/phishing/campaigns/[id]/triage-note/route.ts`
|
||||
- Source assertion: `grep -q "generateAndPostTriageNote" app/api/phishing/campaigns/[id]/triage-note/route.ts`
|
||||
- Source assertion: `grep -q "UUID_RE" app/api/phishing/campaigns/[id]/triage-note/route.ts` and route returns 404 on missing campaign (`grep -q "Campaign not found"`)
|
||||
- Source assertion: route does NOT import or call writeAuditEvent (`! grep -q "writeAuditEvent" app/api/phishing/campaigns/[id]/triage-note/route.ts`)
|
||||
- `npx tsc --noEmit --pretty` reports no errors
|
||||
- `npx next build` (or `npx tsc`) recognizes the route as a valid App Router handler exporting POST
|
||||
</acceptance_criteria>
|
||||
<done>The POST route exists, gates on phishing:analyze, guards the UUID, 404s unknown campaigns, delegates to generateAndPostTriageNote, returns the result verbatim, and type-checks clean.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client → API route | Untrusted caller hits POST /api/phishing/campaigns/{id}/triage-note with an arbitrary campaign id |
|
||||
| campaign evidence → Autotask note | Attacker-controlled email content flows through the formatter into notes written to Autotask tickets |
|
||||
| Autotask API → API response | External write outcomes (incl. error messages) are surfaced back to the caller |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-21-04 | Elevation of Privilege | route permission tier | mitigate | Route calls `requirePermission('phishing', 'analyze')` and early-returns 401/403 before any DB read or Autotask write — same convention as every other /api/phishing/* route (ACCESS-01 carry-forward). Note generation is informational (not a destructive state change), so 'analyze' (not 'approve') is the correct tier per CONTEXT.md discretion. |
|
||||
| T-21-05 | Tampering / Injection | UUID guard + parameterized queries | mitigate | `UUID_RE` rejects malformed ids with 400 before querying; all reads use `$1` parameterized queries (no string interpolation), preventing SQL injection via the id or campaign data. |
|
||||
| T-21-06 | Information Disclosure | note content written to Autotask | mitigate | Note text is produced solely by Plan 01's `formatTriageNote`, which routes all URLs/free-text through `sanitizeNoteText`/`sanitizeUrl` (query strings + tokens stripped) — no raw malicious URL or secret is written. Notes are `noteType:1`/`publish:1` (internal Autotask users, never customer portal per AUTOTASK_API_GUIDE.md). |
|
||||
| T-21-07 | Information Disclosure | partial-failure response detail | accept | Per-ticket `error` returns only `err.message` (Autotask API message), never a stack trace or internal path; the whole-request catch likewise returns only `err.message`. Operator-facing internal tool; bounded error text is acceptable and necessary for the "paste failed note manually" workflow (D-06). |
|
||||
| T-21-08 | Denial of Service / Repudiation | no note-history persistence | accept | No audit/history row is written (CONTEXT.md defers this); Autotask itself is the record of truth for posted notes. Repeated calls posting duplicate notes are expected/acceptable (D-03). |
|
||||
| T-21-SC | Tampering | npm/pip/cargo installs | accept | No new package installs in this plan; RESEARCH disabled for project and no install tasks present. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npx vitest run lib/services/triage-note-service.test.ts` — passes (per-ticket loop + partial-failure + note-text-always-returned)
|
||||
- `npx tsc --noEmit --pretty` — no new type errors
|
||||
- `grep -q "requirePermission('phishing', 'analyze')" app/api/phishing/campaigns/[id]/triage-note/route.ts` — auth gate present
|
||||
- `grep -q "createEntity('TicketNotes'" lib/services/triage-note-service.ts` — safe write path used
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- POST /api/phishing/campaigns/{id}/triage-note is auth-gated (phishing:analyze), UUID-guarded, and 404s unknown campaigns (NOTE-01 + ACCESS-01 carry-forward)
|
||||
- For a classified campaign, an internal triage note summarizing classification + evidence + blast radius + current remediation state is posted to every linked ticket (D-01, D-04)
|
||||
- The response always returns the sanitized note text plus a per-ticket posted/error status list (D-06)
|
||||
- A single ticket's write failure is captured per-ticket without aborting the call and without any unsanitized/partial fallback write (D-05)
|
||||
- All tests pass; type-check clean
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/21-autotask-triage-note/21-02-SUMMARY.md` when done
|
||||
</output>
|
||||
Loading…
Add table
Add a link
Reference in a new issue