From 7303b16cbb8d2aafb8ae8c73a3056ac3e7fa067a Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 15 Jul 2026 13:44:14 -0400 Subject: [PATCH] docs(17): create phase plan (1 plan, 1 wave) --- .planning/ROADMAP.md | 3 +- .../17-01-PLAN.md | 252 ++++++++++++++++++ 2 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 .planning/phases/17-mimecast-blast-radius-lookup/17-01-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 04bac74..b56c972 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -404,7 +404,8 @@ summarizes classification, blast radius, and recommended/approved remediation st 1. When Mimecast is configured, querying the blast-radius abstraction for a message (keyed on message ID, sender, recipient/reporter, subject, and date window) returns normalized delivery data — matched/delivered/held/rejected/clicked counts and per-recipient status 2. When Mimecast is not configured, the same lookup call returns `status: unavailable` synchronously rather than throwing, timing out, or blocking the caller 3. The lookup follows the existing `lib/services/` factory convention (`getMimecastClient()` + `isMimecastConfigured()`-equivalent) so Phase 19's classifier can call it without knowing whether Mimecast is present -**Plans**: TBD +**Plans**: 1 plan +- [ ] 17-01-PLAN.md — isMimecastConfigured() gate + mimecast-blast-radius.ts fan-out/merge/cache orchestration + tests (BLAST-01, BLAST-02) **UI hint**: no ### Phase 18: Campaign Grouping & Phishing Analysis API diff --git a/.planning/phases/17-mimecast-blast-radius-lookup/17-01-PLAN.md b/.planning/phases/17-mimecast-blast-radius-lookup/17-01-PLAN.md new file mode 100644 index 0000000..f304b3f --- /dev/null +++ b/.planning/phases/17-mimecast-blast-radius-lookup/17-01-PLAN.md @@ -0,0 +1,252 @@ +--- +phase: 17-mimecast-blast-radius-lookup +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - lib/services/mimecast-client.ts + - lib/services/mimecast-client.test.ts + - lib/services/mimecast-blast-radius.ts + - lib/services/mimecast-blast-radius.test.ts +autonomous: true +requirements: [BLAST-01, BLAST-02] + +must_haves: + truths: + - "isMimecastConfigured() returns true only when both MIMECAST_CLIENT_ID and MIMECAST_CLIENT_SECRET are set, false otherwise" + - "getBlastRadius() returns status:'unavailable' reason:'not_configured' synchronously (no Mimecast call) when Mimecast is unconfigured" + - "getBlastRadius() when configured returns normalized matched/delivered/held/rejected/clicked counts plus a per-recipient status array, built by fanning out to searchDeliveredMessages + getHeldMessages + getThreatEvents" + - "An unexpected error thrown during the fan-out degrades to status:'unavailable' reason:'lookup_failed' rather than propagating to the caller" + - "A repeated lookup for the same message identity within the cache TTL returns the cached result without re-calling any MimecastClient method" + artifacts: + - path: "lib/services/mimecast-client.ts" + provides: "isMimecastConfigured() config gate + _resetMimecastClient() test seam" + contains: "export function isMimecastConfigured" + - path: "lib/services/mimecast-blast-radius.ts" + provides: "getBlastRadius() orchestration + BlastRadiusInput/BlastRadiusResult types" + exports: ["getBlastRadius", "BlastRadiusInput", "BlastRadiusResult"] + - path: "lib/services/mimecast-client.test.ts" + provides: "Unit tests for isMimecastConfigured() + getMimecastClient() throw/cache behavior" + - path: "lib/services/mimecast-blast-radius.test.ts" + provides: "Unit tests for getBlastRadius() config gate, fan-out merge, never-throw, cache-hit" + key_links: + - from: "lib/services/mimecast-blast-radius.ts" + to: "lib/services/mimecast-client.ts" + via: "import isMimecastConfigured + getMimecastClient" + pattern: "from './mimecast-client'" + - from: "lib/services/mimecast-blast-radius.ts" + to: "lib/services/redis-client.ts" + via: "getCachedData / setCachedData" + pattern: "from './redis-client'" +--- + + +Build the Mimecast blast-radius lookup abstraction: a pure, never-throwing +orchestration function that, given a reported message's identity, returns +normalized delivery data (matched/delivered/held/rejected/clicked counts + +per-recipient status) when Mimecast is configured, and a clean +`status: 'unavailable'` signal when it isn't. Also adds the missing +`isMimecastConfigured()` config-gate helper to `mimecast-client.ts` so the +abstraction (and future callers) can check config presence without triggering +the throw in `getMimecastClient()`. + +Purpose: Phase 19's classifier needs a stable, typed, non-throwing +blast-radius signal it can call without knowing whether Mimecast is present. +This phase delivers exactly that abstraction and nothing more — no schema, no +HTTP route, no UI (per D-03 the lookup is ephemeral). + +Output: `isMimecastConfigured()` + `_resetMimecastClient()` in +`mimecast-client.ts`; new `mimecast-blast-radius.ts` module; two new test files. +Zero new dependencies, zero migrations. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/17-mimecast-blast-radius-lookup/17-CONTEXT.md +@.planning/phases/17-mimecast-blast-radius-lookup/17-RESEARCH.md +@.planning/phases/17-mimecast-blast-radius-lookup/17-PATTERNS.md +@.planning/phases/17-mimecast-blast-radius-lookup/17-VALIDATION.md + + + + + +Existing factory (lib/services/mimecast-client.ts, ~line 645-661): + let _client: MimecastClient | null = null; // module-level singleton + export function getMimecastClient(): MimecastClient // throws 'MIMECAST_CLIENT_ID and MIMECAST_CLIENT_SECRET must be set' when unconfigured + export function getMimecastClientForTenant(tenant: {...}) // DO NOT touch — out of scope (D-05: single global client only) + +Four MimecastClient methods the abstraction composes (return types verified in 17-RESEARCH.md Pattern 3 / 17-PATTERNS.md File 2): + getMessageInfo(messageId: string): Promise + // MimecastMessageInfo = { messageId; bodyText?; bodyHtml?; headers? } — BODY/HEADERS ONLY, no status/counts (Pitfall 1) + searchDeliveredMessages(options: { to?; from?; subject?; startHours?; start?; end?; route? }): Promise<{ messages: MimecastDeliveredMessage[]; error? }> + // MimecastDeliveredMessage.to is a single recipient string; .status is a string (exact rejection enum UNCONFIRMED — see 17-RESEARCH A3). NEVER throws (internal try/catch). + getHeldMessages(options: { recipient?; maxMessages? }): Promise<{ messages: MimecastHeldMessage[]; totalCount }> + // MimecastHeldMessage.to is a single recipient string. CAN throw (paginated loop, no top-level try/catch) — orchestrator MUST wrap. + getThreatEvents(options?: { cursor?; pageSize? }): Promise<{ items: MimecastThreatEvent[]; nextCursor: string | null }> + // MimecastThreatEvent.analysis?: string[]. No server-side sender/subject/date filter — filter client-side. NEVER throws. + +redis-client.ts exports being reused (do NOT hand-roll ioredis): + getCachedData(key: string): Promise // returns null when REDIS_URL unset (silent no-op) + setCachedData(key: string, data: T, ttlSeconds = 300): Promise // default TTL already 300s = D-04's 5 min + +Factory-convention analogs to mirror: + lib/services/pax8-factory.ts — isPax8Configured() one-liner + _resetPax8Client() test seam + lib/services/pax8-factory.test.ts — beforeEach env-var-delete + reset pattern + lib/services/phishing-eml-service.ts — orchestration/graceful-degrade module shape + lib/services/phishing-eml-service.test.ts — vi.mock() factory-mocking discipline (import module AFTER mocks) + app/api/addigy-devices/route.ts — the one place getCachedData/setCachedData is called end-to-end; cache-key format `::` + + + + + + + Task 1: Add isMimecastConfigured() config gate + _resetMimecastClient() test seam + tests + lib/services/mimecast-client.ts, lib/services/mimecast-client.test.ts + + - lib/services/mimecast-client.ts (lines 640-690 — the existing `_client` singleton, `getMimecastClient()`, and `getMimecastClientForTenant()`; add new exports near here, do NOT modify these) + - lib/services/pax8-factory.ts (the isPax8Configured() + _resetPax8Client() shape to mirror exactly) + - lib/services/pax8-factory.test.ts (the beforeEach env-var-delete + reset test structure to mirror) + - lib/services/integration-health.ts (~line 338 — confirms the exact two env vars MIMECAST_CLIENT_ID / MIMECAST_CLIENT_SECRET that the gate must check) + + + - isMimecastConfigured() === false when neither env var is set + - isMimecastConfigured() === false when only MIMECAST_CLIENT_ID is set + - isMimecastConfigured() === false when only MIMECAST_CLIENT_SECRET is set + - isMimecastConfigured() === true when both MIMECAST_CLIENT_ID and MIMECAST_CLIENT_SECRET are set + - getMimecastClient() throws with message 'MIMECAST_CLIENT_ID and MIMECAST_CLIENT_SECRET must be set' when unconfigured + - getMimecastClient() returns the same cached instance on repeated calls once configured (and _resetMimecastClient() clears it between tests) + + + Add two new exports to `lib/services/mimecast-client.ts`, placed next to the existing `getMimecastClient()` (~line 645), mirroring `pax8-factory.ts`: + 1. `export function isMimecastConfigured(): boolean` returning `!!(process.env.MIMECAST_CLIENT_ID && process.env.MIMECAST_CLIENT_SECRET)` — the same two env vars `checkConfigOnly('mimecast', ...)` in integration-health.ts already checks. + 2. `export function _resetMimecastClient(): void` that sets the module-level `_client` singleton (declared line 645) back to `null` — a test seam matching `_resetPax8Client()`, needed so `mimecast-client.test.ts` can isolate env-var state per `it()` block. + Do NOT alter `MimecastClient` internals, `getMimecastClient()`, or `getMimecastClientForTenant()` behavior — this is purely additive. + + Create `lib/services/mimecast-client.test.ts` mirroring `pax8-factory.test.ts`: a `beforeEach` that `delete`s both env vars and calls `_resetMimecastClient()`; a `describe('isMimecastConfigured')` block covering the four env-var combinations in the behavior list; and a `describe('getMimecastClient')` block asserting it throws the exact string 'MIMECAST_CLIENT_ID and MIMECAST_CLIENT_SECRET must be set' when unconfigured and returns the same instance on repeat calls when configured. This is a Wave 0 test-scaffold requirement (17-VALIDATION.md) — it did not exist before this phase. + + + npx vitest run lib/services/mimecast-client.test.ts + npx tsc --noEmit --pretty + + + - `lib/services/mimecast-client.ts` contains `export function isMimecastConfigured(` + - `lib/services/mimecast-client.ts` contains `export function _resetMimecastClient(` + - `grep -c "getMimecastClientForTenant" lib/services/mimecast-client.ts` is unchanged (still present — not deleted/renamed) + - `npx vitest run lib/services/mimecast-client.test.ts` exits 0 with all isMimecastConfigured + getMimecastClient cases passing + - `npx tsc --noEmit --pretty` reports no errors + + + isMimecastConfigured() and _resetMimecastClient() are exported from mimecast-client.ts, existing factory functions untouched, and mimecast-client.test.ts passes. + + + + + Task 2: Build mimecast-blast-radius.ts orchestration (fan-out merge, never-throw, Redis cache) + tests + lib/services/mimecast-blast-radius.ts, lib/services/mimecast-blast-radius.test.ts + + - lib/services/mimecast-client.ts (return-type interfaces MimecastMessageInfo / MimecastDeliveredMessage / MimecastHeldMessage / MimecastThreatEvent and the four method signatures — build the merge against the real field names) + - lib/services/redis-client.ts (getCachedData / setCachedData signatures + default 300s TTL + REDIS_URL-unset no-op behavior) + - lib/services/phishing-eml-service.ts (module doc-comment convention + graceful-degrade posture; NOTE: it rethrows at top level — this module must NOT rethrow) + - lib/services/phishing-eml-service.test.ts (vi.mock() discipline: declare mocks before importing the module under test) + - app/api/addigy-devices/route.ts (lines 1-45 — the `::` cache-key format + explicit 300 TTL call) + - .planning/phases/17-mimecast-blast-radius-lookup/17-RESEARCH.md (Pattern 3 = exact per-recipient merge algorithm; Pitfall 1 = getMessageInfo has no counts; Pitfall 2 = clicked best-effort; A3 = status enum unconfirmed) + + + - Not configured (isMimecastConfigured mocked false) → resolves { status:'unavailable', reason:'not_configured' } and getMimecastClient is never called + - Configured + cache hit (getCachedData resolves a fixture result) → returns the cached BlastRadiusResult and none of the four MimecastClient methods are called + - Configured + cache miss + fan-out success → merges delivered/held/threat-event fixtures into { status:'ok', matched, delivered, held, rejected, clicked, perRecipient[], source:'fan-out' }, then calls setCachedData exactly once + - Fan-out call rejects (e.g. getHeldMessages mockRejectedValue) → resolves { status:'unavailable', reason:'lookup_failed', error } and does NOT throw to the caller + - clicked === N when threat-event fixtures include a click-type analysis[] value; clicked === 0 when none match (best-effort, not "confirmed zero") + - perRecipient groups delivered+held rows by recipient: delivered rows → 'delivered', held rows → 'held', recipients in neither result set → 'unknown' + + + Create `lib/services/mimecast-blast-radius.ts` following the `phishing-eml-service.ts` module shape. Open with a doc-comment documenting three load-bearing facts: (a) the lookup is ephemeral per D-03 (no persistence); (b) `clicked` is best-effort derived from getThreatEvents()'s analysis[] subtype — Mimecast's real click data lives in the currently-unwrapped `/api/ttp/url/get-logs` endpoint (17-RESEARCH Pitfall 2), so `clicked: 0` means "no click-type threat event found," not "confirmed zero clicks"; (c) KNOWN LIMITATION D-05 — uses only the single global env-var `getMimecastClient()`, NOT the per-company `mimecast_tenants` table / `getMimecastClientForTenant()`, so reports from companies with their own Mimecast tenant will return `unavailable` even though Mimecast is technically configured for them. + + Export `BlastRadiusInput` with required fields `sender: string`, `recipient: string`, `subject: string`, `dateWindow: { start: Date; end: Date }` and optional `messageId?: string`. Keeping sender/subject/dateWindow non-optional is the V5 input-validation control for T-17-01 (never allow a date-range-only fan-out). Export `BlastRadiusResult` as a discriminated union: `{ status:'unavailable'; reason:'not_configured'|'lookup_failed'; error?: string }` OR `{ status:'ok'; matched:number; delivered:number; held:number; rejected:number; clicked:number; perRecipient: Array<{recipient:string; status:'delivered'|'held'|'rejected'|'unknown'}>; source:'fan-out' }`. + + Export `async function getBlastRadius(input: BlastRadiusInput): Promise`: + 1. If `!isMimecastConfigured()` return `{ status:'unavailable', reason:'not_configured' }` synchronously — do NOT construct the client (that would throw). + 2. Build the cache key `::` per addigy-devices precedent: with messageId → `mimecast:blast-radius:msgid:${input.messageId}`; else → `mimecast:blast-radius:composite:${input.sender}:${input.subject}:${input.dateWindow.start.toISOString()}:${input.dateWindow.end.toISOString()}`. + 3. `const cached = await getCachedData(cacheKey); if (cached) return cached;` — a cache hit must short-circuit BEFORE any MimecastClient call (D-04). + 4. Inside a `try`: `const client = getMimecastClient();`. If `input.messageId` is present, optionally `await client.getMessageInfo(input.messageId)` for body/header evidence ONLY — never use its return to derive counts and never let it gate the fan-out (Pitfall 1). Then ALWAYS run the fan-out via `Promise.all([...])` (D-01 corrected — fan-out is unconditional, not a fallback): `searchDeliveredMessages({ to: input.recipient, from: input.sender, subject: input.subject, start, end })` where `start`/`end` are `input.dateWindow.start/end.toISOString().replace(/\.\d{3}Z$/, '+0000')`; `getHeldMessages({ recipient: input.recipient })`; `getThreatEvents()`. + 5. Merge per 17-RESEARCH Pattern 3: build `perRecipient[]` by grouping delivered + held rows on their single-string `to` field (delivered → 'delivered', held → 'held', neither → 'unknown'); `delivered` = count of delivered rows, `held` = count of held rows, `rejected` = delivered rows whose `.status` string indicates rejection, `matched` = total delivered + held rows. Because the exact `.status` rejection enum is UNCONFIRMED (17-RESEARCH A3), add a code comment noting this and treat unrecognized status strings conservatively as 'delivered' rather than hardcoding a guessed rejected value as confirmed; log the raw status values seen at debug level for first-real-tenant validation. Derive `clicked` best-effort by counting threat-event items whose `analysis[]` includes a click-type value, else `0` (comment the best-effort limitation). + 6. `await setCachedData(cacheKey, result, 300);` then return the result. + 7. `catch (err)`: `console.error('[MIMECAST-BLAST-RADIUS] lookup failed', err instanceof Error ? err.message : err)` — log err.message ONLY, never full Mimecast response bodies which may contain other recipients' subjects/content (T-17-02) — and return `{ status:'unavailable', reason:'lookup_failed', error: err instanceof Error ? err.message : String(err) }`. This satisfies BLAST-02: the public entry point never throws. + + Create `lib/services/mimecast-blast-radius.test.ts` mirroring `phishing-eml-service.test.ts`: declare `vi.mock('./mimecast-client', ...)` (exporting mocked `isMimecastConfigured`, `getMimecastClient` returning an object with the four vi.fn() methods) and `vi.mock('./redis-client', ...)` (mocked `getCachedData`/`setCachedData`) BEFORE importing `getBlastRadius`. Cover every case in the behavior list, using inline synthetic objects matching the verified MimecastDeliveredMessage / MimecastHeldMessage / MimecastThreatEvent shapes — no fixture file needed. Assert call counts (e.g. `expect(searchDeliveredMessages).not.toHaveBeenCalled()` on the not-configured and cache-hit paths) to prove the short-circuits. + + + npx vitest run lib/services/mimecast-blast-radius.test.ts lib/services/mimecast-client.test.ts + npx tsc --noEmit --pretty + npm test + + + - `lib/services/mimecast-blast-radius.ts` contains `export async function getBlastRadius(` + - `lib/services/mimecast-blast-radius.ts` contains `export interface BlastRadiusInput` and `export type BlastRadiusResult` (or `export interface` for the result union parts) + - `grep -q "from './mimecast-client'" lib/services/mimecast-blast-radius.ts` and `grep -q "from './redis-client'" lib/services/mimecast-blast-radius.ts` both succeed + - `grep -qi "D-05" lib/services/mimecast-blast-radius.ts` — the multi-tenant known-limitation comment is present + - The module's `getBlastRadius` returns `{ status:'unavailable', reason:'not_configured' }` without calling getMimecastClient when unconfigured (asserted by test with call-count check) + - A mocked fan-out rejection produces `{ status:'unavailable', reason:'lookup_failed' }` and the promise resolves (does not reject) — asserted by test + - A cache-hit test asserts none of the four MimecastClient methods were called + - `npx vitest run lib/services/mimecast-blast-radius.test.ts lib/services/mimecast-client.test.ts` exits 0 + - `npx tsc --noEmit --pretty` reports no errors + - `npm test` (full suite) exits 0 + + + getBlastRadius() composes the fan-out into a normalized shape, never throws (returns 'unavailable' on unconfigured or unexpected error), caches via redis-client with a 300s TTL and short-circuits on cache hit, documents the D-02 clicked and D-05 multi-tenant limitations in code, and the full test suite is green. + + + + + + +Reuses the ASVS L1 applicability analysis already performed in 17-RESEARCH.md +"Security Domain" — V5 Input Validation applies; V2/V3/V4/V6 do not (no HTTP +route, no session, no new crypto in this phase). No new external packages are +installed, so no supply-chain (T-*-SC) checkpoint is required +(17-RESEARCH "Package Legitimacy Audit": not applicable). + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Phase 19 classifier (in-process) → getBlastRadius() input | Caller-supplied message identity crosses into Mimecast query params; must be bounded (sender + subject + date-window, or Message-ID) — never a date-range-only query | +| getBlastRadius() → Mimecast API (external, via MimecastClient) | Outbound query + inbound response bodies that may contain other recipients' email metadata/content | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-17-01 | Information Disclosure | mimecast-blast-radius.ts fan-out query | mitigate | `BlastRadiusInput` makes `sender`, `subject`, and `dateWindow` non-optional (V5). Never call `searchDeliveredMessages`/`getHeldMessages` with only a date range — a too-broad query could return another company's unrelated messages. Enforced at the type level and by the required-field input contract. | +| T-17-02 | Information Disclosure | error/log paths in getBlastRadius catch block | mitigate | Log only `err.message` (never full Mimecast response bodies, which may include other recipients' subjects/content), matching the existing `console.error` discipline in mimecast-sync-service.ts / the mimecast API routes. | +| T-17-03 | Tampering / Information Disclosure | Redis cache key | accept | v1 uses only the single global env-var `getMimecastClient()` (D-05), so all cached lookups belong to one tenant — no cross-company key collision is possible. Documented in code: IF per-company tenant resolution is added later (Open Question #1), the cache key MUST be extended with a company/tenant discriminator to prevent one company's cached blast-radius being served for another's identically-subjected report. | + + + +- `npx vitest run lib/services/mimecast-client.test.ts lib/services/mimecast-blast-radius.test.ts` — both new test files green (BLAST-01 fan-out merge + config gate; BLAST-02 unavailable-on-unconfigured + never-throw-on-error; D-04 cache short-circuit). +- `npm test` — full suite green (no regressions in the ~10 existing lib test files). +- `npx tsc --noEmit --pretty` — no type errors across the new module + modified client. +- `grep -qi "D-05" lib/services/mimecast-blast-radius.ts` — multi-tenant known-limitation comment present. +- Manual inspection: `getMimecastClientForTenant` still present and unmodified in mimecast-client.ts (out-of-scope function not disturbed). + + + +1. BLAST-01: When Mimecast is configured, `getBlastRadius({ messageId?, sender, recipient, subject, dateWindow })` returns normalized matched/delivered/held/rejected/clicked counts + per-recipient status, built by fanning out to searchDeliveredMessages + getHeldMessages + getThreatEvents (proven by the fan-out-success test). +2. BLAST-02: When Mimecast is not configured, the same call returns `status: 'unavailable'` synchronously without throwing, timing out, or blocking; and an unexpected fan-out error degrades to `status: 'unavailable'` reason `'lookup_failed'` rather than propagating (both proven by tests). +3. The lookup follows the factory convention: `isMimecastConfigured()` now exists alongside `getMimecastClient()`, so Phase 19's classifier can call `getBlastRadius()` without knowing whether Mimecast is present. +4. Zero new dependencies, zero migrations (D-03 ephemeral), no UI/HTTP route added. + + + +Create `.planning/phases/17-mimecast-blast-radius-lookup/17-01-SUMMARY.md` when done. +