chore: merge executor worktree (worktree-agent-a5e56aaa233a4df21)
This commit is contained in:
commit
30bfa127bb
7 changed files with 626 additions and 4 deletions
|
|
@ -50,11 +50,11 @@ destructive remediation gated behind explicit human approval.
|
||||||
|
|
||||||
### Blast Radius (Mimecast)
|
### Blast Radius (Mimecast)
|
||||||
|
|
||||||
- [ ] **BLAST-01**: The system can query a Mimecast blast-radius abstraction for
|
- [x] **BLAST-01**: The system can query a Mimecast blast-radius abstraction for
|
||||||
message delivery data (matched/delivered/held/rejected/clicked counts,
|
message delivery data (matched/delivered/held/rejected/clicked counts,
|
||||||
per-recipient status) when Mimecast is configured, keyed on message ID,
|
per-recipient status) when Mimecast is configured, keyed on message ID,
|
||||||
sender, recipient/reporter, subject, and date window
|
sender, recipient/reporter, subject, and date window
|
||||||
- [ ] **BLAST-02**: When Mimecast is not configured, the system records
|
- [x] **BLAST-02**: When Mimecast is not configured, the system records
|
||||||
`status: unavailable` for that lookup and classification proceeds using
|
`status: unavailable` for that lookup and classification proceeds using
|
||||||
ticket/email evidence alone — it never blocks on missing Mimecast config
|
ticket/email evidence alone — it never blocks on missing Mimecast config
|
||||||
|
|
||||||
|
|
@ -159,8 +159,8 @@ Populated during roadmap creation.
|
||||||
| CAMP-01 | Phase 18 | Pending |
|
| CAMP-01 | Phase 18 | Pending |
|
||||||
| CAMP-02 | Phase 18 | Pending |
|
| CAMP-02 | Phase 18 | Pending |
|
||||||
| CAMP-03 | Phase 18 | Pending |
|
| CAMP-03 | Phase 18 | Pending |
|
||||||
| BLAST-01 | Phase 17 | Pending |
|
| BLAST-01 | Phase 17 | Complete |
|
||||||
| BLAST-02 | Phase 17 | Pending |
|
| BLAST-02 | Phase 17 | Complete |
|
||||||
| CLASSIFY-01 | Phase 19 | Pending |
|
| CLASSIFY-01 | Phase 19 | Pending |
|
||||||
| CLASSIFY-02 | Phase 19 | Pending |
|
| CLASSIFY-02 | Phase 19 | Pending |
|
||||||
| CLASSIFY-03 | Phase 19 | Pending |
|
| CLASSIFY-03 | Phase 19 | Pending |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
---
|
||||||
|
phase: 17-mimecast-blast-radius-lookup
|
||||||
|
plan: 01
|
||||||
|
subsystem: api
|
||||||
|
tags: [mimecast, redis-cache, blast-radius, phishing-triage, vitest]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 15-data-model-detection-ticket-evidence
|
||||||
|
provides: migrations/097_phishing_triage_schema.sql (classifications.reasons JSONB — the eventual persistence point for a blast-radius result, not touched by this phase)
|
||||||
|
provides:
|
||||||
|
- "isMimecastConfigured() config gate + _resetMimecastClient() test seam in lib/services/mimecast-client.ts"
|
||||||
|
- "getBlastRadius() orchestration in lib/services/mimecast-blast-radius.ts — normalized matched/delivered/held/rejected/clicked counts + perRecipient status, never-throwing, Redis-cached (5min TTL)"
|
||||||
|
affects: [19-classifier-service]
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "is<Name>Configured() config-gate convention extended to mimecast-client.ts (matches pax8-factory.ts/veeam-factory.ts)"
|
||||||
|
- "Never-throw orchestration wrapper: config gate returns synchronously, unexpected errors degrade to a discriminated-union 'unavailable' result instead of propagating"
|
||||||
|
- "Redis cache-key format <service>:<resource>:<discriminators> (mirrors app/api/addigy-devices/route.ts)"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- lib/services/mimecast-blast-radius.ts
|
||||||
|
- lib/services/mimecast-blast-radius.test.ts
|
||||||
|
- lib/services/mimecast-client.test.ts
|
||||||
|
modified:
|
||||||
|
- lib/services/mimecast-client.ts
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "Fan-out (searchDeliveredMessages + getHeldMessages + getThreatEvents) runs unconditionally, not as a fallback after getMessageInfo — per corrected D-01, getMessageInfo() has no status/count data"
|
||||||
|
- "clicked is best-effort from getThreatEvents() analysis[] click-type match; documented in code as not a confirmed-zero signal (D-02)"
|
||||||
|
- "Delivered-message .status rejection classification treats unrecognized strings as non-rejected (conservative) since the real enum (17-RESEARCH A3) is unconfirmed; raw values logged at console.debug for first-real-tenant validation"
|
||||||
|
- "Single global getMimecastClient() only for v1 — per-company mimecast_tenants/getMimecastClientForTenant() explicitly out of scope, documented as D-05 known limitation in the module doc-comment"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Blast-radius abstraction is a pure function: config-gate → cache-check → fan-out → merge → cache-write → return; every branch returns a typed discriminated union, never throws"
|
||||||
|
|
||||||
|
requirements-completed: [BLAST-01, BLAST-02]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: ~20min
|
||||||
|
completed: 2026-07-15
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 17 Plan 01: Mimecast Blast Radius Lookup Summary
|
||||||
|
|
||||||
|
**New `getBlastRadius()` orchestration composes Mimecast's searchDeliveredMessages/getHeldMessages/getThreatEvents into normalized matched/delivered/held/rejected/clicked counts + per-recipient status, gated by a new `isMimecastConfigured()` and Redis-cached for 5 minutes — never throws, degrades to `status: 'unavailable'` on missing config or unexpected error.**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** ~20 min
|
||||||
|
- **Completed:** 2026-07-15T18:30:12Z
|
||||||
|
- **Tasks:** 2 completed
|
||||||
|
- **Files modified:** 4 (1 modified, 3 created)
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- `isMimecastConfigured()` + `_resetMimecastClient()` added to `mimecast-client.ts`, matching the project's `is<Name>Configured()` factory convention (mirrors `pax8-factory.ts`)
|
||||||
|
- `lib/services/mimecast-blast-radius.ts`: pure, never-throwing `getBlastRadius()` that fans out to the three existing `MimecastClient` methods unconditionally (not gated on `getMessageInfo`), merges into a normalized shape, and short-circuits on a Redis cache hit (5-min TTL, matching D-04)
|
||||||
|
- Both new test files (13 tests total) pass; full `npm test` shows no new failures (2 pre-existing, unrelated `itglue-search.test.ts` failures logged to `deferred-items.md`, out of scope for this plan)
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: Add isMimecastConfigured() config gate + _resetMimecastClient() test seam + tests** - `8b032c3` (feat)
|
||||||
|
2. **Task 2: Build mimecast-blast-radius.ts orchestration (fan-out merge, never-throw, Redis cache) + tests** - `efbc437` (feat)
|
||||||
|
|
||||||
|
_Both tasks were tdd="true"; test files were authored alongside the implementation in the same commit per the plan's task grouping (test file + implementation in one commit, matching the plan's `<files>` grouping rather than separate RED/GREEN commits)._
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `lib/services/mimecast-client.ts` - Added `isMimecastConfigured()` + `_resetMimecastClient()`; `getMimecastClient()` and `getMimecastClientForTenant()` untouched
|
||||||
|
- `lib/services/mimecast-client.test.ts` - New: covers `isMimecastConfigured()` env-var combinations + `getMimecastClient()` throw/cache/reset behavior
|
||||||
|
- `lib/services/mimecast-blast-radius.ts` - New: `BlastRadiusInput`/`BlastRadiusResult` types + `getBlastRadius()` orchestration
|
||||||
|
- `lib/services/mimecast-blast-radius.test.ts` - New: covers not-configured short-circuit, cache-hit short-circuit, fan-out merge (delivered/rejected/held/clicked/perRecipient), never-throw-on-error, and unknown-recipient classification
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- Fan-out is unconditional (corrected D-01) — `getMessageInfo()` is called only as a supplementary body/header fetch when `messageId` is present, and never gates whether the fan-out runs
|
||||||
|
- `clicked` best-effort derivation and the D-05 multi-tenant known-limitation are both documented directly in the module's doc-comment, per the plan's acceptance criteria (`grep -qi "D-05"` passes)
|
||||||
|
- Delivered-message rejection status classification is conservative (unrecognized → non-rejected) since the real Mimecast `.status` enum is unconfirmed (17-RESEARCH A3); raw values are logged via `console.debug` for future validation against a real tenant, not hardcoded as a guessed enum
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None — plan executed exactly as written. Both tasks' `<action>` and `<behavior>` requirements were implemented as specified; all acceptance criteria (exports present, D-05 comment present, test/tsc/npm-test green) verified directly.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
Full `npm test` run surfaces 2 pre-existing failures in `lib/services/analyzer/itglue-search.test.ts` (`client.getFlexibleAssetsForOrganization is not a function`), unrelated to any file this plan touched. Verified via `git log` that the last commit affecting those files (`a0a6e7f`) predates this plan's work. Logged to `.planning/phases/17-mimecast-blast-radius-lookup/deferred-items.md` per the SCOPE BOUNDARY rule (only auto-fix issues directly caused by the current task's changes) — not fixed here.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
|
||||||
|
None - no external service configuration required. This phase reuses the existing `MIMECAST_CLIENT_ID`/`MIMECAST_CLIENT_SECRET`/`REDIS_URL` env vars already documented in CLAUDE.md; no new env vars, migrations, or dependencies were introduced.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
`getBlastRadius({ messageId?, sender, recipient, subject, dateWindow })` is ready for Phase 19's classifier to call directly — it never throws, and its `BlastRadiusResult` discriminated union (`status: 'ok' | 'unavailable'`) is fully typed. Known, documented limitation: v1 uses only the single global env-var Mimecast client, not per-company `mimecast_tenants` — Phase 19 (or a later phase) should be aware that companies with their own registered Mimecast tenant will see `status: 'unavailable'` from this abstraction even when Mimecast is otherwise configured for them.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
All created files and commit hashes verified present.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 17-mimecast-blast-radius-lookup*
|
||||||
|
*Completed: 2026-07-15*
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Deferred Items — Phase 17, Plan 01
|
||||||
|
|
||||||
|
Out-of-scope discoveries found during execution. Not fixed per SCOPE BOUNDARY
|
||||||
|
(only auto-fix issues directly caused by the current task's changes).
|
||||||
|
|
||||||
|
## Pre-existing test failures unrelated to this plan
|
||||||
|
|
||||||
|
`npm test` (full suite) shows 2 pre-existing failures in
|
||||||
|
`lib/services/analyzer/itglue-search.test.ts`:
|
||||||
|
|
||||||
|
- `itglueSearch > returns capped, redacted doc snippets when the org is found`
|
||||||
|
- `itglueSearch > tolerates per-call failures (configurations errors, flex still returns)`
|
||||||
|
|
||||||
|
Both fail with a `client.getFlexibleAssetsForOrganization is not a function`
|
||||||
|
stderr log and count-mismatch assertions. Neither `lib/services/analyzer/
|
||||||
|
itglue-search.ts` nor its test file was touched by Phase 17 Plan 01 (which only
|
||||||
|
modified `lib/services/mimecast-client.ts` and added
|
||||||
|
`lib/services/mimecast-blast-radius.ts` + their test files). Last commit
|
||||||
|
touching those files: `a0a6e7f fix(itglue): list flexible assets per type to
|
||||||
|
satisfy API 422 requirement` — predates this plan's work. Left unfixed; not
|
||||||
|
in scope for Phase 17.
|
||||||
227
lib/services/mimecast-blast-radius.test.ts
Normal file
227
lib/services/mimecast-blast-radius.test.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
/**
|
||||||
|
* mimecast-blast-radius.ts — getBlastRadius() orchestration tests.
|
||||||
|
*
|
||||||
|
* Mocks './mimecast-client' and './redis-client' entirely (following
|
||||||
|
* phishing-eml-service.test.ts's vi.mock() factory-mocking discipline) — no
|
||||||
|
* real Mimecast network calls or Redis connection are used. Synthetic inline
|
||||||
|
* fixtures matching MimecastDeliveredMessage/MimecastHeldMessage/
|
||||||
|
* MimecastThreatEvent shapes are used directly; no fixture file needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
const isMimecastConfiguredMock = vi.fn();
|
||||||
|
const getMessageInfoMock = vi.fn();
|
||||||
|
const searchDeliveredMessagesMock = vi.fn();
|
||||||
|
const getHeldMessagesMock = vi.fn();
|
||||||
|
const getThreatEventsMock = vi.fn();
|
||||||
|
const getMimecastClientMock = vi.fn(() => ({
|
||||||
|
getMessageInfo: getMessageInfoMock,
|
||||||
|
searchDeliveredMessages: searchDeliveredMessagesMock,
|
||||||
|
getHeldMessages: getHeldMessagesMock,
|
||||||
|
getThreatEvents: getThreatEventsMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./mimecast-client', () => ({
|
||||||
|
isMimecastConfigured: () => isMimecastConfiguredMock(),
|
||||||
|
getMimecastClient: () => getMimecastClientMock(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getCachedDataMock = vi.fn();
|
||||||
|
const setCachedDataMock = vi.fn();
|
||||||
|
vi.mock('./redis-client', () => ({
|
||||||
|
getCachedData: (...args: unknown[]) => getCachedDataMock(...args),
|
||||||
|
setCachedData: (...args: unknown[]) => setCachedDataMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import AFTER the mocks are declared so vi.mock hoisting takes effect.
|
||||||
|
import { getBlastRadius, type BlastRadiusInput } from './mimecast-blast-radius';
|
||||||
|
|
||||||
|
const BASE_INPUT: BlastRadiusInput = {
|
||||||
|
sender: 'attacker@evil.test',
|
||||||
|
recipient: 'reporter@wulfconsulting.test',
|
||||||
|
subject: 'Urgent: verify your account',
|
||||||
|
dateWindow: {
|
||||||
|
start: new Date('2026-07-14T00:00:00.000Z'),
|
||||||
|
end: new Date('2026-07-15T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('getBlastRadius', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
isMimecastConfiguredMock.mockReset().mockReturnValue(true);
|
||||||
|
getMessageInfoMock.mockReset().mockResolvedValue(null);
|
||||||
|
searchDeliveredMessagesMock.mockReset().mockResolvedValue({ messages: [] });
|
||||||
|
getHeldMessagesMock.mockReset().mockResolvedValue({ messages: [], totalCount: 0 });
|
||||||
|
getThreatEventsMock.mockReset().mockResolvedValue({ items: [], nextCursor: null });
|
||||||
|
getCachedDataMock.mockReset().mockResolvedValue(null);
|
||||||
|
setCachedDataMock.mockReset().mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unavailable/not_configured synchronously and never calls getMimecastClient when unconfigured', async () => {
|
||||||
|
isMimecastConfiguredMock.mockReturnValue(false);
|
||||||
|
|
||||||
|
const result = await getBlastRadius(BASE_INPUT);
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'unavailable', reason: 'not_configured' });
|
||||||
|
expect(getMimecastClientMock).not.toHaveBeenCalled();
|
||||||
|
expect(searchDeliveredMessagesMock).not.toHaveBeenCalled();
|
||||||
|
expect(getHeldMessagesMock).not.toHaveBeenCalled();
|
||||||
|
expect(getThreatEventsMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the cached result on a cache hit without calling any MimecastClient method', async () => {
|
||||||
|
const cachedResult = {
|
||||||
|
status: 'ok' as const,
|
||||||
|
matched: 1,
|
||||||
|
delivered: 1,
|
||||||
|
held: 0,
|
||||||
|
rejected: 0,
|
||||||
|
clicked: 0,
|
||||||
|
perRecipient: [{ recipient: BASE_INPUT.recipient, status: 'delivered' as const }],
|
||||||
|
source: 'fan-out' as const,
|
||||||
|
};
|
||||||
|
getCachedDataMock.mockResolvedValue(cachedResult);
|
||||||
|
|
||||||
|
const result = await getBlastRadius(BASE_INPUT);
|
||||||
|
|
||||||
|
expect(result).toEqual(cachedResult);
|
||||||
|
expect(searchDeliveredMessagesMock).not.toHaveBeenCalled();
|
||||||
|
expect(getHeldMessagesMock).not.toHaveBeenCalled();
|
||||||
|
expect(getThreatEventsMock).not.toHaveBeenCalled();
|
||||||
|
expect(setCachedDataMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges delivered/held/threat-event fixtures into a normalized ok result and caches it once', async () => {
|
||||||
|
searchDeliveredMessagesMock.mockResolvedValue({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'd1',
|
||||||
|
status: 'Delivered',
|
||||||
|
subject: BASE_INPUT.subject,
|
||||||
|
from: BASE_INPUT.sender,
|
||||||
|
fromEnv: BASE_INPUT.sender,
|
||||||
|
to: BASE_INPUT.recipient,
|
||||||
|
toDisplay: 'Reporter',
|
||||||
|
received: '2026-07-14T12:00:00+0000',
|
||||||
|
senderIP: '1.2.3.4',
|
||||||
|
spamScore: 0,
|
||||||
|
detectionLevel: 'none',
|
||||||
|
attachments: false,
|
||||||
|
route: 'inbound',
|
||||||
|
info: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'd2',
|
||||||
|
status: 'Rejected',
|
||||||
|
subject: BASE_INPUT.subject,
|
||||||
|
from: BASE_INPUT.sender,
|
||||||
|
fromEnv: BASE_INPUT.sender,
|
||||||
|
to: 'other-recipient@wulfconsulting.test',
|
||||||
|
toDisplay: 'Other',
|
||||||
|
received: '2026-07-14T12:05:00+0000',
|
||||||
|
senderIP: '1.2.3.4',
|
||||||
|
spamScore: 0,
|
||||||
|
detectionLevel: 'none',
|
||||||
|
attachments: false,
|
||||||
|
route: 'inbound',
|
||||||
|
info: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
getHeldMessagesMock.mockResolvedValue({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'h1',
|
||||||
|
subject: BASE_INPUT.subject,
|
||||||
|
from: BASE_INPUT.sender,
|
||||||
|
fromDisplay: 'Attacker',
|
||||||
|
to: 'held-recipient@wulfconsulting.test',
|
||||||
|
toDisplay: 'Held',
|
||||||
|
dateReceived: '2026-07-14T13:00:00+0000',
|
||||||
|
reason: 'spam',
|
||||||
|
reasonCode: 'SPAM',
|
||||||
|
policyInfo: '',
|
||||||
|
route: 'inbound',
|
||||||
|
hasAttachments: false,
|
||||||
|
size: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalCount: 1,
|
||||||
|
});
|
||||||
|
getThreatEventsMock.mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 't1',
|
||||||
|
eventType: 'click',
|
||||||
|
analysis: ['click'],
|
||||||
|
source: [],
|
||||||
|
direction: [],
|
||||||
|
status: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextCursor: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getBlastRadius(BASE_INPUT);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'ok',
|
||||||
|
matched: 2, // 1 non-rejected delivered + 1 held
|
||||||
|
delivered: 1,
|
||||||
|
held: 1,
|
||||||
|
rejected: 1,
|
||||||
|
clicked: 1,
|
||||||
|
perRecipient: expect.arrayContaining([
|
||||||
|
{ recipient: BASE_INPUT.recipient, status: 'delivered' },
|
||||||
|
{ recipient: 'other-recipient@wulfconsulting.test', status: 'rejected' },
|
||||||
|
{ recipient: 'held-recipient@wulfconsulting.test', status: 'held' },
|
||||||
|
]),
|
||||||
|
source: 'fan-out',
|
||||||
|
});
|
||||||
|
expect(setCachedDataMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setCachedDataMock).toHaveBeenCalledWith(expect.any(String), result, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicked is 0 when no click-type analysis value is present', async () => {
|
||||||
|
getThreatEventsMock.mockResolvedValue({
|
||||||
|
items: [{ id: 't1', eventType: 'malware', analysis: ['malware'], source: [], direction: [], status: [] }],
|
||||||
|
nextCursor: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getBlastRadius(BASE_INPUT);
|
||||||
|
|
||||||
|
expect(result.status).toBe('ok');
|
||||||
|
if (result.status === 'ok') {
|
||||||
|
expect(result.clicked).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('degrades to unavailable/lookup_failed (never throws) when a fan-out call rejects', async () => {
|
||||||
|
getHeldMessagesMock.mockRejectedValue(new Error('Mimecast API timeout'));
|
||||||
|
|
||||||
|
const result = await getBlastRadius(BASE_INPUT);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'unavailable',
|
||||||
|
reason: 'lookup_failed',
|
||||||
|
error: 'Mimecast API timeout',
|
||||||
|
});
|
||||||
|
expect(setCachedDataMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a recipient with no delivered/held rows at all is reported as unknown', async () => {
|
||||||
|
const result = await getBlastRadius(BASE_INPUT);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'ok',
|
||||||
|
matched: 0,
|
||||||
|
delivered: 0,
|
||||||
|
held: 0,
|
||||||
|
rejected: 0,
|
||||||
|
clicked: 0,
|
||||||
|
perRecipient: [{ recipient: BASE_INPUT.recipient, status: 'unknown' }],
|
||||||
|
source: 'fan-out',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
195
lib/services/mimecast-blast-radius.ts
Normal file
195
lib/services/mimecast-blast-radius.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
/**
|
||||||
|
* Mimecast blast-radius lookup abstraction (Phase 17).
|
||||||
|
*
|
||||||
|
* Given a reported message's identity (Message-ID, sender, recipient,
|
||||||
|
* subject, date window), returns normalized delivery data — matched/
|
||||||
|
* delivered/held/rejected/clicked counts and per-recipient status — when
|
||||||
|
* Mimecast is configured, or a clean `status: 'unavailable'` signal (never a
|
||||||
|
* throw) when it isn't or when the underlying lookup fails unexpectedly.
|
||||||
|
*
|
||||||
|
* Three load-bearing facts:
|
||||||
|
*
|
||||||
|
* (a) EPHEMERAL (D-03): this lookup is a pure function call — nothing is
|
||||||
|
* persisted here. If Phase 19's classifier wants to keep a result, it
|
||||||
|
* writes to its own `classifications.reasons` JSONB column; that is
|
||||||
|
* Phase 19's concern, not this module's.
|
||||||
|
*
|
||||||
|
* (b) `clicked` IS BEST-EFFORT (D-02): it is derived from getThreatEvents()'s
|
||||||
|
* analysis[] subtype containing a click-like value. Mimecast's real
|
||||||
|
* click-tracking data (TTP URL Protect logs) lives in a separate,
|
||||||
|
* currently-unwrapped `/api/ttp/url/get-logs` endpoint (see
|
||||||
|
* 17-RESEARCH.md Pitfall 2) — this module does not call it. A value of
|
||||||
|
* `clicked: 0` means "no click-type threat event found in the events
|
||||||
|
* this module can see," NOT "confirmed zero clicks."
|
||||||
|
*
|
||||||
|
* (c) KNOWN LIMITATION — MULTI-TENANT GAP (D-05): this module uses only the
|
||||||
|
* single global env-var-configured getMimecastClient(), NOT the
|
||||||
|
* per-company `mimecast_tenants` table / getMimecastClientForTenant().
|
||||||
|
* Reports belonging to companies with their own registered Mimecast
|
||||||
|
* tenant (not covered by the global MIMECAST_CLIENT_ID) will return
|
||||||
|
* `status: 'unavailable'` even though Mimecast is technically configured
|
||||||
|
* for that company. Per-tenant resolution is a deliberate, documented v1
|
||||||
|
* gap — not a silent oversight — and would be a small, mechanical
|
||||||
|
* follow-up later (swap getMimecastClient() for a tenant lookup +
|
||||||
|
* getMimecastClientForTenant(), same fan-out/merge logic below).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
isMimecastConfigured,
|
||||||
|
getMimecastClient,
|
||||||
|
type MimecastDeliveredMessage,
|
||||||
|
type MimecastHeldMessage,
|
||||||
|
} from './mimecast-client';
|
||||||
|
import { getCachedData, setCachedData } from './redis-client';
|
||||||
|
|
||||||
|
export interface BlastRadiusInput {
|
||||||
|
/** Optional Mimecast Message-ID — used only for supplementary body/header
|
||||||
|
* evidence via getMessageInfo(); it never gates or replaces the fan-out. */
|
||||||
|
messageId?: string;
|
||||||
|
/** Reported message's sender address. Required — see T-17-01: never allow
|
||||||
|
* a date-range-only fan-out query. */
|
||||||
|
sender: string;
|
||||||
|
/** Reporter/reported-to recipient address. Required — see T-17-01. */
|
||||||
|
recipient: string;
|
||||||
|
/** Reported message's subject. Required — see T-17-01. */
|
||||||
|
subject: string;
|
||||||
|
/** Date window to search within. Required — see T-17-01. */
|
||||||
|
dateWindow: { start: Date; end: Date };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BlastRadiusResult =
|
||||||
|
| { 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';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort delivered/rejected split. The exact string values Mimecast
|
||||||
|
* returns in MimecastDeliveredMessage.status have NOT been confirmed against
|
||||||
|
* a live/sandbox tenant (17-RESEARCH.md A3 / Open Question #2) — only the
|
||||||
|
* TypeScript field name and type (`string`) are confirmed from the client
|
||||||
|
* code. Treat unrecognized status strings conservatively as NOT rejected
|
||||||
|
* (i.e. delivered) rather than hardcoding a guessed enum as if it were
|
||||||
|
* confirmed. Log raw values at debug level so a first real-tenant run can
|
||||||
|
* validate/refine this heuristic.
|
||||||
|
*/
|
||||||
|
function isRejectedStatus(status: string): boolean {
|
||||||
|
return /reject/i.test(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort click-type detection (D-02) — see module doc-comment (b). */
|
||||||
|
function isClickEvent(analysis: string[] | undefined): boolean {
|
||||||
|
return (analysis ?? []).some((a) => /click/i.test(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBlastRadius(input: BlastRadiusInput): Promise<BlastRadiusResult> {
|
||||||
|
if (!isMimecastConfigured()) {
|
||||||
|
return { status: 'unavailable', reason: 'not_configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = input.messageId
|
||||||
|
? `mimecast:blast-radius:msgid:${input.messageId}`
|
||||||
|
: `mimecast:blast-radius:composite:${input.sender}:${input.subject}:${input.dateWindow.start.toISOString()}:${input.dateWindow.end.toISOString()}`;
|
||||||
|
|
||||||
|
const cached = await getCachedData<BlastRadiusResult>(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = getMimecastClient();
|
||||||
|
|
||||||
|
// Supplementary only — body/header evidence, never gates the fan-out
|
||||||
|
// (17-RESEARCH.md Pitfall 1: getMessageInfo() has no status/counts).
|
||||||
|
if (input.messageId) {
|
||||||
|
await client.getMessageInfo(input.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startStr = input.dateWindow.start.toISOString().replace(/\.\d{3}Z$/, '+0000');
|
||||||
|
const endStr = input.dateWindow.end.toISOString().replace(/\.\d{3}Z$/, '+0000');
|
||||||
|
|
||||||
|
// D-01 (corrected): the fan-out runs unconditionally — it is the only
|
||||||
|
// source of the matched/delivered/held/rejected/clicked counts.
|
||||||
|
const [deliveredResult, heldResult, threatsResult] = await Promise.all([
|
||||||
|
client.searchDeliveredMessages({
|
||||||
|
to: input.recipient,
|
||||||
|
from: input.sender,
|
||||||
|
subject: input.subject,
|
||||||
|
start: startStr,
|
||||||
|
end: endStr,
|
||||||
|
}),
|
||||||
|
client.getHeldMessages({ recipient: input.recipient }),
|
||||||
|
client.getThreatEvents(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const deliveredRows: MimecastDeliveredMessage[] = deliveredResult.messages ?? [];
|
||||||
|
const heldRows: MimecastHeldMessage[] = heldResult.messages ?? [];
|
||||||
|
|
||||||
|
const rawStatuses = Array.from(new Set(deliveredRows.map((m) => m.status)));
|
||||||
|
if (rawStatuses.length > 0) {
|
||||||
|
console.debug('[MIMECAST-BLAST-RADIUS] raw delivered-message status values seen:', rawStatuses);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rejectedRows = deliveredRows.filter((m) => isRejectedStatus(m.status));
|
||||||
|
const nonRejectedDeliveredRows = deliveredRows.filter((m) => !isRejectedStatus(m.status));
|
||||||
|
|
||||||
|
const delivered = nonRejectedDeliveredRows.length;
|
||||||
|
const rejected = rejectedRows.length;
|
||||||
|
const held = heldRows.length;
|
||||||
|
// matched = delivered + held; a rejected recipient was never actually
|
||||||
|
// delivered, so rejected is NOT included in matched.
|
||||||
|
const matched = delivered + held;
|
||||||
|
|
||||||
|
const clicked = (threatsResult.items ?? []).filter((t) => isClickEvent(t.analysis)).length;
|
||||||
|
|
||||||
|
// Per-recipient merge: group by the single-string `to` field. Delivered
|
||||||
|
// (non-rejected) rows → 'delivered', rejected rows → 'rejected', held
|
||||||
|
// rows → 'held' (held overwrites a same-recipient delivered entry — a
|
||||||
|
// held message for a recipient is the more actionable signal). Ensure
|
||||||
|
// the originally-queried recipient is always represented, even if
|
||||||
|
// neither result set returned a row for them ('unknown').
|
||||||
|
const perRecipientMap = new Map<string, 'delivered' | 'held' | 'rejected' | 'unknown'>();
|
||||||
|
for (const row of nonRejectedDeliveredRows) {
|
||||||
|
if (row.to) perRecipientMap.set(row.to, 'delivered');
|
||||||
|
}
|
||||||
|
for (const row of rejectedRows) {
|
||||||
|
if (row.to) perRecipientMap.set(row.to, 'rejected');
|
||||||
|
}
|
||||||
|
for (const row of heldRows) {
|
||||||
|
if (row.to) perRecipientMap.set(row.to, 'held');
|
||||||
|
}
|
||||||
|
if (!perRecipientMap.has(input.recipient)) {
|
||||||
|
perRecipientMap.set(input.recipient, 'unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
const perRecipient = Array.from(perRecipientMap.entries()).map(([recipient, status]) => ({
|
||||||
|
recipient,
|
||||||
|
status,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result: BlastRadiusResult = {
|
||||||
|
status: 'ok',
|
||||||
|
matched,
|
||||||
|
delivered,
|
||||||
|
held,
|
||||||
|
rejected,
|
||||||
|
clicked,
|
||||||
|
perRecipient,
|
||||||
|
source: 'fan-out',
|
||||||
|
};
|
||||||
|
|
||||||
|
await setCachedData(cacheKey, result, 300);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
// T-17-02: log err.message ONLY, never full Mimecast response bodies
|
||||||
|
// (which may contain other recipients' subjects/content).
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error('[MIMECAST-BLAST-RADIUS] lookup failed', message);
|
||||||
|
return { status: 'unavailable', reason: 'lookup_failed', error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
63
lib/services/mimecast-client.test.ts
Normal file
63
lib/services/mimecast-client.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
/**
|
||||||
|
* mimecast-client.ts — isMimecastConfigured()/getMimecastClient() factory tests.
|
||||||
|
*
|
||||||
|
* Mirrors pax8-factory.test.ts's beforeEach env-var-delete + reset seam
|
||||||
|
* pattern. Does not test MimecastClient's other 20+ methods — out of scope
|
||||||
|
* for this phase (Phase 17, Plan 01, Task 1).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { isMimecastConfigured, getMimecastClient, _resetMimecastClient } from './mimecast-client';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.MIMECAST_CLIENT_ID;
|
||||||
|
delete process.env.MIMECAST_CLIENT_SECRET;
|
||||||
|
_resetMimecastClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isMimecastConfigured', () => {
|
||||||
|
it('returns false when neither env var is set', () => {
|
||||||
|
expect(isMimecastConfigured()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when only MIMECAST_CLIENT_ID is set', () => {
|
||||||
|
process.env.MIMECAST_CLIENT_ID = 'id1';
|
||||||
|
expect(isMimecastConfigured()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when only MIMECAST_CLIENT_SECRET is set', () => {
|
||||||
|
process.env.MIMECAST_CLIENT_SECRET = 'secret1';
|
||||||
|
expect(isMimecastConfigured()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when both env vars are set', () => {
|
||||||
|
process.env.MIMECAST_CLIENT_ID = 'id1';
|
||||||
|
process.env.MIMECAST_CLIENT_SECRET = 'secret1';
|
||||||
|
expect(isMimecastConfigured()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getMimecastClient', () => {
|
||||||
|
it('throws the exact configuration error when not configured', () => {
|
||||||
|
expect(() => getMimecastClient()).toThrow(
|
||||||
|
'MIMECAST_CLIENT_ID and MIMECAST_CLIENT_SECRET must be set'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the same cached instance on repeated calls', () => {
|
||||||
|
process.env.MIMECAST_CLIENT_ID = 'id1';
|
||||||
|
process.env.MIMECAST_CLIENT_SECRET = 'secret1';
|
||||||
|
const first = getMimecastClient();
|
||||||
|
const second = getMimecastClient();
|
||||||
|
expect(second).toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebuilds a new instance after _resetMimecastClient()', () => {
|
||||||
|
process.env.MIMECAST_CLIENT_ID = 'id1';
|
||||||
|
process.env.MIMECAST_CLIENT_SECRET = 'secret1';
|
||||||
|
const first = getMimecastClient();
|
||||||
|
_resetMimecastClient();
|
||||||
|
const second = getMimecastClient();
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -644,6 +644,16 @@ export class MimecastClient {
|
||||||
|
|
||||||
let _client: MimecastClient | null = null;
|
let _client: MimecastClient | null = null;
|
||||||
|
|
||||||
|
export function isMimecastConfigured(): boolean {
|
||||||
|
return !!(process.env.MIMECAST_CLIENT_ID && process.env.MIMECAST_CLIENT_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test seam — reset the cached client (e.g. after rotating credentials, or
|
||||||
|
// to isolate env-var state between test cases).
|
||||||
|
export function _resetMimecastClient(): void {
|
||||||
|
_client = null;
|
||||||
|
}
|
||||||
|
|
||||||
export function getMimecastClient(): MimecastClient {
|
export function getMimecastClient(): MimecastClient {
|
||||||
if (!_client) {
|
if (!_client) {
|
||||||
const clientId = process.env.MIMECAST_CLIENT_ID;
|
const clientId = process.env.MIMECAST_CLIENT_ID;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue