Scoping a dedicated "User Awareness" verdict for confirmed phishing-simulation
reports (KnowBe4/Breach Secure Now) plus a per-company automation gate for the
parse/classify/report-to-ticket pipeline stages. Surfaced live during Phase 22
review of a real Breach Secure Now report (ticket 699415).
classifications.confidence is 0.0-1.0 (1 = max confidence). ClassificationCard
appended "%" directly to the raw value, so confidence=1 rendered as "1%
confidence" -- read as near-zero, the opposite of its true meaning. Found live
while reviewing a Breach Secure Now simulated-phishing classification.
Mimecast blast-radius fixes: future end-date no longer silently swallowed
into a false-clean result, and campaigns from companies with their own
registered mimecast_tenants row (e.g. Seubert & Associates) now resolve
against that tenant instead of always querying the global Wulf tenant.
Documents the getBlastRadius per-tenant injection/swallowed-error fixes,
the campaign detail route's date-window clamp + tenant resolution, the
in-scope test-hygiene deviation, and the pre-existing out-of-scope
itglue-search.test.ts failure.
Bug 1: clamp dateWindow.end to Math.min(createdAt + 24h, Date.now()) so a
freshly-detected campaign (<24h old primary report) never sends Mimecast a
future end-date -- previously rejected as err_track_and_trace_invalid_end_date
and swallowed internally as a false-clean zero-count result.
Bug 2 (D-05): add company_id to the reports SELECT and, when the reporting
company has its own enabled mimecast_tenants row, resolve a tenant-scoped
client via getMimecastClientForTenant() and thread it into getBlastRadius as
{ client, cacheScope: companyId }. Falls back to the global env-configured
client when no company-specific tenant is registered.
Covers the already-implemented per-tenant factory: returns a MimecastClient
instance, builds a new independent instance per call (never the cached
global), doesn't affect getMimecastClient()'s singleton, and defaults
base_url when omitted. Uses fake credentials only.
- getBlastRadius(input, options?) accepts an optional injected MimecastClient
and cacheScope; an injected client bypasses the global isMimecastConfigured()
gate since it carries its own credentials
- cache key namespaced by cacheScope to prevent cross-tenant collisions
- deliveredResult.error (previously swallowed) now rethrown so the outer
catch converts it to status: unavailable / reason: lookup_failed --
defense-in-depth against Bug 1 (future end-date rejected by Mimecast)
- test mock hygiene: getMimecastClientMock now cleared in beforeEach
- Fake tenant client via options.client bypasses getMimecastClient
- Injected tenant client runs fan-out even when global env unconfigured
- searchDeliveredMessages error field now expected to degrade to unavailable/lookup_failed
All 6 plans merged: pure-logic extraction (resolver/default-params/timeline),
API read surface, evidence/classification/timeline cards, action-area card,
and the ticket-scoped review page + campaigns list + nav entry. Blocking
LiveLink checkpoint verified against a real Autotask ticket (numeric ticket
ID confirmed, no ticket_number fallback needed).
Task 3 (blocking human-verify) resolved as "verified": a real production
Autotask LiveLink click against ticket 699340 confirmed the numeric-
ticket-id resolver assumption, no ticket_number fallback needed. Marks
REVIEW-01, REVIEW-05, REVIEW-06 complete in REQUIREMENTS.md.
- app/phishing/page.tsx: minimal DataTable-backed campaigns list, row click
navigates to /phishing/tickets/{firstReportTicketId}, EmptyState when no
campaigns exist yet
- components/navigation/app-navigation.tsx: add flat "Phishing" nav item
(ShieldAlert icon) immediately after PAX8, visible to all roles (every
role has phishing:read)
- app/phishing/tickets/[ticketId]/page.tsx: resolves ticket->campaign via
the plan-02 resolver route, drives a loading/not-triaged/ungrouped/ready/
error state machine, branches ready into grouped-but-unclassified
(Classify CTA, no ClassificationCard/ActionAreaCard) vs. classified (all
four cards with explicit props), refetches after every action (D-04),
session-only auth (no token/query-param scheme)
- app/api/phishing/reports/[report_id]/route.ts (new, additive): thin
report-scoped evidence + fresh blast-radius lookup for the D-08
ungrouped-report state, which has no campaignId to key the existing
campaign-detail route on — added as a Rule 2 dependency since the plan's
own D-08 truth ("standalone-report notice + evidence") has no other data
source
- Alias campaigns table as c, add correlated subquery for the earliest
linked report's ticket_id so the list page can navigate a row click
straight to /phishing/tickets/{firstReportTicketId}
- Additive only: count query, limit/offset, requirePermission gate, and
the { items, total, limit, offset } envelope all unchanged
- Add GatedButton: buttons stay in the DOM (D-05) always, wrapped in a
Tooltip explanation when disabled
- Gate all three actions with hasPermission(role, 'phishing', 'approve'|
'remediate') from lib/permissions.ts — the identical check the server
routes enforce, never a bespoke role === 'admin' string check (REVIEW-06)
- Derive resolved = campaignStatus === 'false_positive' OR any completed
remediation_actions row; resolved-state tooltip mirrors the completed
action's approver/date or the campaign's updated_at
- Remediate: AlertDialog confirmation listing approved action count/types,
then POST /remediate (no body), refetch on success
- Mark as false positive: AlertDialog with optional reason Textarea, then
POST /mark-false-positive { reason? }; disabled reason mirrors the
server's 409 guard exactly (approved/completed remediation blocks it)
- New GET /api/phishing/tickets/{ticket_id}/campaign wraps
resolveTicketToCampaign() from plan 22-01
- requirePermission('phishing','read') gate, Number.isFinite param
validation, D-07: found:false at 200 (not 404) for untriaged tickets
- Create ActionAreaCard with a null-guard on classification (renders an
informational note, never dereferences recommendedActions, for the
default grouped-but-unclassified state)
- Render one checkbox row per recommended action with an always-visible
params form pre-filled via deriveDefaultParams(actionType, evidence)
- Submit exact ApproveActionInput[] to POST /approve; purge_message
mailboxes is edited as a comma-separated string and normalized to
string[] at submit time
- Refetch via onActionComplete() on success (D-04, no optimistic mutation)
- Add 22-04-SUMMARY.md documenting the two components delivered
- Log pre-existing unrelated itglue-search.test.ts failures to
deferred-items.md (out of scope for this plan)
- Mark REVIEW-02, REVIEW-04 complete in REQUIREMENTS.md
- components/phishing/evidence-card.tsx renders Headers, URLs,
Attachments, Body preview, and Blast Radius tabs for a selected
message
- Body preview renders inside a <pre className="whitespace-pre-wrap">
as plain JSX text only, never via a raw-HTML injection prop
- URLs tab delegates to UrlList (D-09 inert copy-only)
- Blast radius renders explicit unavailable-state copy or a
matched/delivered/held/rejected/clicked stat row + per-recipient
table when ok
- CardTitle explicitly overridden with font-bold per UI-SPEC typography
- Merges reports/classifications/audit-events into one ascending list
(relies on server ordering, no client-side sort)
- Per-kind icon/label/tint: FileText for reports, Sparkles tinted by
verdict for classifications, event_type table for audit rows
(remediation_approved/completed, campaign_marked_false_positive,
campaign_classified, humanized fallback for anything else)
- 8px rail dot + border-l connector per UI-SPEC Timeline Spec
- Discriminated union TimelineEntry with report/classification/audit variants
- Ascending sort by createdAt with stable report<classification<audit tie-break
- Covers ascending chronological sort across reports/classifications/audit
- Asserts discriminant kind + source fields per variant, plus stable tie-break order
- Pure switch over no_action/warn_user/block_sender/purge_message/
reset_password/isolate_endpoint/disable_forwarding_rule
- Unknown/future action types fall through to {} rather than throwing
- Renders latest classification verdict/confidence/summary/reasons
- Recommended-action chips (informational, no checkboxes)
- Requires-approval warning Alert when requiresApproval is true
- Reclassify button gated on hasPermission(role, 'phishing', 'analyze')
- Returns null when classification is missing (empty-state handled by plan 06)
- npx shadcn add tooltip generates components/ui/tooltip.tsx (official
registry, no npm dependency added)
- components/phishing/url-list.tsx renders extracted URLs as inert
<code> text with copy-to-clipboard only — no <a>/href, no <Link>,
no navigating onClick per D-09
- Pure lookup: reports row for a ticket id -> found/reportId/campaignId/ticketNumber
- Parameterized query only (WHERE ticket_id = $1), no requirePermission/NextResponse