docs(17): create phase plan (1 plan, 1 wave)

This commit is contained in:
lorentz 2026-07-15 13:44:14 -04:00
parent 78ff39fa48
commit 7303b16cbb
2 changed files with 254 additions and 1 deletions

View file

@ -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

View file

@ -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'"
---
<objective>
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.
</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/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
<interfaces>
<!-- Verified exports from lib/services/mimecast-client.ts (read the file to confirm; do NOT modify these). -->
<!-- Executor uses these signatures directly — no exploration needed. -->
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 | null>
// 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<T>(key: string): Promise<T | null> // returns null when REDIS_URL unset (silent no-op)
setCachedData<T>(key: string, data: T, ttlSeconds = 300): Promise<void> // 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 `<service>:<resource>:<discriminators>`
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add isMimecastConfigured() config gate + _resetMimecastClient() test seam + tests</name>
<files>lib/services/mimecast-client.ts, lib/services/mimecast-client.test.ts</files>
<read_first>
- 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)
</read_first>
<behavior>
- 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)
</behavior>
<action>
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.
</action>
<verify>
<automated>npx vitest run lib/services/mimecast-client.test.ts</automated>
<automated>npx tsc --noEmit --pretty</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>
isMimecastConfigured() and _resetMimecastClient() are exported from mimecast-client.ts, existing factory functions untouched, and mimecast-client.test.ts passes.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Build mimecast-blast-radius.ts orchestration (fan-out merge, never-throw, Redis cache) + tests</name>
<files>lib/services/mimecast-blast-radius.ts, lib/services/mimecast-blast-radius.test.ts</files>
<read_first>
- 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 `<service>:<resource>:<discriminators>` 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)
</read_first>
<behavior>
- 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'
</behavior>
<action>
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<BlastRadiusResult>`:
1. If `!isMimecastConfigured()` return `{ status:'unavailable', reason:'not_configured' }` synchronously — do NOT construct the client (that would throw).
2. Build the cache key `<service>:<resource>:<discriminators>` 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<BlastRadiusResult>(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.
</action>
<verify>
<automated>npx vitest run lib/services/mimecast-blast-radius.test.ts lib/services/mimecast-client.test.ts</automated>
<automated>npx tsc --noEmit --pretty</automated>
<automated>npm test</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>
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.
</done>
</task>
</tasks>
<threat_model>
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. |
</threat_model>
<verification>
- `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).
</verification>
<success_criteria>
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.
</success_criteria>
<output>
Create `.planning/phases/17-mimecast-blast-radius-lookup/17-01-SUMMARY.md` when done.
</output>