Skip to content

feat(bec): add full BEC case workflow - #490

Draft
Zacgoose wants to merge 27 commits into
devfrom
preview/bec-case-workflow
Draft

feat(bec): add full BEC case workflow#490
Zacgoose wants to merge 27 commits into
devfrom
preview/bec-case-workflow

Conversation

@Zacgoose

@Zacgoose Zacgoose commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

This pull request introduces significant improvements and new features for BEC (Business Email Compromise) detection and containment, along with some foundational utilities and configuration enhancements. The main changes include the addition of a comprehensive BEC heuristics configuration, new cmdlets for BEC containment actions, and support for automated containment of newly detected high-risk users.

Key changes:

BEC Containment and Remediation

  • Added Get-CIPPBecContainmentActions cmdlet, providing a catalog of all available BEC containment actions, their descriptions, impacts, and execution order. This serves as the single source of truth for what containment actions are available and how they are presented to operators and automation.
  • Added Disable-CIPPInboxRules cmdlet to selectively or fully disable a mailbox's inbox rules as part of BEC containment, skipping system and Exchange-managed delegate rules, and reporting outcomes for each processed rule.
  • Implemented opt-in automated BEC containment for users newly detected at high risk in Get-CIPPAlertNewRiskyUsers, running the default containment steps and logging the results directly in the alert payload. [1] [2] [3]

Configuration and Heuristics

  • Introduced a new BecHeuristics.json configuration file, defining detection windows, scoring weights, thresholds, suspicious patterns, and action caps for BEC detection logic. This externalizes and centralizes the heuristics, making them easier to tune and audit.

Utilities and Support Functions

  • Added ConvertTo-CIPPBecHostAddress utility function to normalize audit-log addresses by stripping ports and IPv6 brackets, enabling consistent host correlation across the platform.
  • Enhanced Get-CIPPAsyncDeployment to include a LastUpdate field, allowing callers to detect abandoned or stale jobs based on their last modification timestamp.Replace the old single-user BEC check/remediate flow with a full case-based investigation system. This adds new backend collectors, heuristics/scoring, report storage, containment orchestration, evidence export, Purview content-search actions, bulk queueing, and progress tracking; updates alert automation and logging context; and introduces new user/report pages and components for run history, containment, phishing spread, evidence export, and BEC reports. Documentation and broad backend/frontend tests were added to cover the new behavior.

@Zacgoose
Zacgoose force-pushed the preview/bec-case-workflow branch 7 times, most recently from 665a892 to 0539fb0 Compare September 6, 2026 10:32
Comment thread frontend/src/components/CippComponents/CippBecEvidenceDownload.jsx Fixed
@Zacgoose
Zacgoose marked this pull request as ready for review September 7, 2026 06:29
@Zacgoose
Zacgoose marked this pull request as draft September 7, 2026 11:10
@Zacgoose
Zacgoose force-pushed the preview/bec-case-workflow branch 2 times, most recently from e51f68c to 50d15f7 Compare September 8, 2026 10:30
…estigation

Push-BECRun computed and stored several fields nothing reads, and paid Graph
calls for them: LastSuspectUserLogon (a second sign-in query for the row the
top-50 query already returns first), TenantLastSignIns (50 unrelated users'
sign-ins that only ever landed in an evidence CSV), HuntressFeedAvailable,
TransportRuleTotal, DefenderAvailable, AcceptedDomains and ContentPolicy.
All are gone, along with ResultsBytes on the run row, Score.Version and the
ResultsBlob branch for rows no version ever wrote.

The `if ($Scope -eq 'Full')` block was guarded by a constant; the block is
now unconditional and the queue item no longer carries a Scope. The
Get-Mailbox preflight is gone too: the inventory's own error already reaches
the classifier through $Mark and becomes the same skipped marker.

The malicious-app catalog was loaded twice per run. The orchestrator now
takes the merged rogue-app feed once and shares it with the consent
collector, so AddedApps and MaliciousSPs match the same list (Huntress
entries included). The feed's table cache is dropped; the per-worker memo
stays. The heuristics loader loses its memo/hot-reload, the always-true
catalog flag and four JSON keys nothing read.

Also: the async-deployment row now carries the tenant, every `+=` in the
collectors is gone, and the small helpers (case id, run steps, collector
result count, score weight) are one expression each.
Set-CIPPTransportRuleState, Disable-CIPPMailboxApp,
Set-CIPPServicePrincipalState, Set-CIPPCASMailboxProtocols and
Set-CIPPEntraDeviceState were each called from exactly one arm of the
containment dispatcher, always with the same polarity, and each repeated the
try/catch, normalisation and logging the dispatcher already does per action.
The Exchange and Graph calls now sit in those arms; registered devices go
through the existing Set-CIPPDeviceState.

Remove-CIPPUserOAuthGrant had the same loop twice (grants, app-role
assignments); it is one loop over two sets. The dispatcher reads parameters
by member access (case-insensitive on both hashtables and deserialised
objects) instead of rebuilding a key map, and offers -Redacted so the webhook
action and the risky-users alert no longer carry their own copy of the
password-redaction loop. The dead "Failed" state check on session revocation
is gone: the helper throws on failure.
ListBECEvidence had no caller: the runs hub and the case page both build the
package through ExecBECEvidenceExport so the browser-rendered PDFs can be
included. ExecBECBulkCheck's Selection=ForeignSuccessfulSignIns sweep and the
userId/userid spellings had no caller either, and ExecBECCheck's GET
overwrite=true existed for a page this branch deleted.

ListBECRemediationActions returns the array directly, as list endpoints do;
the containment drawer and the alert form consume it as such. ExecBECReport's
one-arm switch is an if, ListBECPhishingSpread no longer loads the heuristics
file for a default the trace helper already has, and its days parameter is
parsed inside the try so a bad value is a formatted error rather than a 500.
ExecBECRemediate tolerates a user id that resolves to no UPN.

Docs: the evidence package holds report-full.pdf and report-summary.pdf, and
the reports-page download includes both. The stray root node_modules ignore
is removed.

The containment drawer (which consumes the catalog) also reads each
action's ParameterName from that catalog instead of a hand-kept id map, and
builds its picker options once instead of twice.
The triage header counted every inbox rule as suspicious while the evidence
groups counted only the flagged ones; it now reads the same becFindingFlags
predicates. The PDF report imports the same predicates, the objective groups
and buildBecTimeline instead of carrying its own copies of the score fallback
(unreachable: every run stores a server-side score), the objective breakdown
and a 14-source event builder, so the page and the report agree. The
report's hardcoded "7 days" strings follow the run's analysis window, and it
reads RiskState.Listed, the field the collector actually emits. The timeline
util gains the Intune-enrolment source the report had.

CippBecRunStatusCard's completed branch could never render (the case page
shows the triage header in that state); it and its props are gone. The two
evidence-export files shared their base64, render and download code and one
exported a button nothing imported; one hook now serves the hub and the case
page. The containment drawer reads each action's ParameterName from the
catalog instead of a hand-kept map and builds its picker options once. The
remediation history uses CippDataTable. The window-start computation lives
in one helper, the unused finding flag/error keys and eventCount are gone,
CippRemediationCard (orphaned by the deleted page) is removed, and the user's
Compromise Remediation tab points straight at the case workspace instead of
relying on the 404 redirect.
… action's original four steps

An explicit MobileDeviceIds pick that the run's inventory did not know (no
run, or an API caller) was turned into a row whose Guid was the DeviceID, so
Remove-MobileDevice got the wrong identity. Picks are now resolved against the
run first and, when any is unknown or there is no run, against the mailbox's
partnerships (Get-MobileDevice, as the existing removal helper does); an id
that still matches nothing is reported as an error row instead of guessed.

The audit-log alert action Execute a BEC Remediate ran four steps before
containment became selectable (password, sign-in, sessions, inbox rules).
Routing it through the dispatcher had silently widened that to the drawer's
six defaults for every rule with no stored selection, adding MFA removal and
OneDrive sharing to automated remediation. The action now passes its original
four explicitly when the rule has none; a rule that wants more picks them.
Generated by the dev module watcher after the endpoint trim: ListBECEvidence
is gone and ListBECRemediationActions returns the array directly.
Every run has been the full investigation since the case workflow landed;
the Scope value only survived as a label for pre-rewrite rows. The run,
the queue item, the history row and the evidence package no longer carry
it (the poll and list responses drop it in the next commit, with the
evidence fields), the status card's "Quick check
(older run)" chip and the containment drawer's "run the full analysis"
hint are gone, and the PDF report no longer gates its deeper sections on
it. The report sample data and the reports-page docs follow.
The package is what the analyst hands on: the results, a CSV per finding
set, the score, the containment history, the case logbook and both
browser-rendered PDFs, built fresh and streamed back in the ZIP. The
SHA-256 manifest, the ZIP hash and the per-run export ledger
(EvidenceExports, EvidenceSha256, EvidenceCreatedAt, EvidenceBytes) served
a verification story nothing in the UI completed, so they are removed:
the run row is no longer written on export, the reports list and the poll
drop the evidence fields and the HasEvidence column, the export button
tooltip no longer shows a hash, and the docs describe the package as it
is.
Eight of the eleven hand-written finding renderers in the objective groups
were the same shape: a summary line, a table, titled tables below it and
prose for an empty result. Those are now fields on the finding definition
in bec-objectives (rows, sections, summary, alert, empty) and one generic
renderer draws them; only Identity Protection (dismiss dialog), mailbox
state (property list) and received mail (spread button and row action)
keep a renderer of their own.

Two things the old per-user page showed come back through the same data:
the sent-mail analysis lists its repeated subjects and send bursts again,
and the application findings show the catalog source (CIPP's own
MaliciousApps.json, the Huntress feed, or both), categories and
description for every match, not just the name.
…ulk users

The orchestrator reworded any Intune "An error has occurred" response to
say the tenant may not have Intune provisioned, and the error classifier
matched exactly that phrase, so a transient 5xx from the Intune service
was recorded as a skipped check rather than a failed one. The rewording
now says it is transient and asks for a rerun; the classifier's own
not-provisioned patterns still match the real DeviceFE messages.

The bulk check returned "User not found" rows in a Cases array nothing
displays. The result text now names how many selected users could not be
resolved and which ids were skipped.
… log

CodeQL flagged the evidence hook's catch: the case id flowed from the row
into the thrown message and from there into console.error. The thrown
message no longer carries the id (the operator clicked that case), and the
log line carries only the error message with line breaks stripped, rather
than the raw error object.
…ng column

A run that has never been contained has no Containment column, and
@($null).Count is 1, so every fresh run listed one containment run on the
BEC hub. Count only the stored entries.
The evidence export, run delete, reports list, phishing-spread and
containment-catalog endpoints, the directory-audit, registered-device,
non-interactive sign-in, mail-activity and risk-state collectors, and the
OAuth-grant, delegation and targeted-CA-policy mutators had no tests of
their own; the containment dispatcher only stubbed them. Each now has a
focused suite in the existing stub-and-mock style: the request contract,
the projection, the boundary and cap logic, the guards and the error
paths.

Writing them found the fresh-run ContainmentRuns miscount fixed in the
previous commit.
…d on each change

Unified-audit-log client addresses carry the source port, so the same host
appeared as several sources in the correlation graph, split the mailbox
activity counts into one group per connection, and showed as
115.70.126.106:16590 in the tables. Every collector now projects the
address through ConvertTo-CIPPBecHostAddress (the geo helper's own
normalisation), and the geo pass uses the same helper.

The projected change rows (inbox rules, safelists, sharing, transport
rules, and the permission changes that target the investigated mailbox)
now carry the full audit record as AuditData, so the More Info panel and
the evidence CSVs hold every detail the audit log had. Tenant-wide
permission changes that do not target the mailbox stay slim.

The transport-rule finding showed each diverting rule twice: as its audit
event and as the current rule. It is now one row per flagged rule with the
latest change (operation, actor, source, country, when) folded in, and
only changes to rules that are not currently flagged are listed below.
The Intune device query ran before the licence preflight, so a tenant
without Intune got Graph "Request not applicable to target tenant" back
and the case page showed it as a warning - the one check that surfaced a
missing licence as a failure instead of the "not checked" note Identity
Protection and Defender get.

The preflight (subscribedSkus) now runs before the tenant batch, and the
managedDevices request is only sent when the tenant has an INTUNE_*
service plan; otherwise the marker is a skip that reads "requires an
Intune licence". When the plans could not be read the query still runs,
and the error classifier now recognises "not applicable to target
tenant" as the same licence skip.
Below the md breakpoint every finding table is a card list that opens at
the desktop page size (25 cards, capped at 50), so a case page with two
sign-in lists alone was fifty tall cards deep. An embedded list can now
ask for a smaller first page through mobileCard.pageSize and grows by
that step on "Load more"; the BEC finding tables use ten.
…TenantCapabilities

The run read subscribedSkus itself, halfway through, to gate Identity
Protection, Defender and the Intune device query. It now uses the shared
Get-CIPPTenantCapabilities read (cached per tenant) once at the start,
and the plan names are the ones the standards presets use, so the gates
agree with the rest of CIPP on what "has Intune" or "has Defender P2"
means. A failed read still runs every check and leaves the error
classifier to decide.
…ow count

The unified audit log was already time-bound (a slice that fills its page
budget is bisected on time), but the Graph-side logs around it were not:
interactive and non-interactive sign-ins took the 50 newest rows with no
date filter, risk detections stopped at 50, the message trace stopped at
five pages, Defender analysed mail stopped at the first 1,000 messages
tenant-wide, and the directory-audit collector reported a full page as
"partial" even though the batch helper had already merged every
continuation page. Sent messages, mail-activity groups and transport-rule
changes were also cut when stored.

Every one of those now reads to the end of the analysis window and stores
what it read. The sign-in and detection queries carry the window start and
page through; the trace walker follows its cursor until a short page; the
Defender window is streamed page by page through the recipient match so
only this mailbox rows are held however busy the tenant is; the
directory-audit collector reports partial only when the helper flags a
failed continuation page. The only entries left under caps in the
heuristics are the two audit-log slice budgets, which bound time, not
rows.
…tail, fold mass mailings in the timeline

Inbox rules have no timestamp of their own, so each rule row now carries
the latest audited change to it (operation, when, from which address and
country); the full change history stays below. The row also keeps every
property Get-InboxRule returned - conditions, exceptions, actions, the
description - minus Exchange plumbing and empty values, so More Info
shows what the rule actually does instead of five picked fields.

Past twenty-five sent messages the timeline folds sends into one event
per hour and source address with the distinct message and recipient
counts and the top subject, so a thousand onward phishes no longer bury
every other event.

The user docs described a Purview content search and purge card that is
not in the product; the section now describes the spread trace that is.
The recently-added-users check already listed guests (the user query has
no type filter) but did not say which rows were guests, and the flagged
directory activities did not include inviting or creating a user. New
users now carry userType, and "Invite external user" and "Add user" are
flagged - scoped, like every directory audit in the run, to events where
the investigated user was the actor or the target.
…ions on the account

Customer-tenant audit records name a partner (GDAP) identity in the two
shapes the audit-log pipeline already maps back to partner users:
user_<id>@<tenant>.onmicrosoft.com in Entra records and
<customer>.onmicrosoft.com\tenant: <partner tenant id>, object: <id> in
Exchange records. The partner-user cache block in Test-CIPPAuditLogRules
is now the shared Get-CIPPPartnerUserLookup (same memo, same table row),
and Resolve-CIPPAuditActor classifies an actor as the tenant own user, a
partner identity (this partner or another, by tenant id), CIPP own
service principal (by app id), an application or a system account, with
the partner user resolved to its name.

The BEC run stamps ActorKind and ActorResolved on every actor-bearing
row - inbox-rule, safelist, sharing, permission and transport-rule
changes, directory audits (which now carry the initiating id) and
mailbox activity. The case page shows the kind beside each actor, folds
who changed a rule into the rule row, and adds a "Partner and CIPP
actions on this account" finding that gathers every such change from all
sources, newest first, so routine MSP work reads as such and anything a
partner identity did that the investigation did not expect stands out.
The score is unchanged.
…rill-in

The "By user" toggle only reduced the flat list to one row per user and dropped a couple of columns, so with one run per user it looked like the same table minus columns. Each summary row now carries its full run history and a "View runs" action that opens every run for that user in a drawer, with the same per-run actions as the flat view (open, download evidence, delete a specific run) - so grouping no longer hides the individual runs. Delete refreshes the grouped list too. Summary columns now lead with the run count, and the triage filters apply to both views.
The by-user view fed the table collapsed rows via `data` but shared the flat view's queryKey. CippDataTable's internal query read that key's cached per-run list (an infinite-query {pages} object) and its success effect overwrote the grouped rows, so the list never actually collapsed - it only looked like the flat table with a couple of columns dropped. The grouped source fetch shared the key too, so read as a plain array it saw the {pages} object and returned nothing. Give the grouped table and its source fetch their own keys, and invalidate both keys on delete and on queuing an investigation.
@Zacgoose
Zacgoose force-pushed the preview/bec-case-workflow branch from 25a457f to ba5dd13 Compare September 9, 2026 09:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants