feat(bec): add full BEC case workflow - #490
Draft
Zacgoose wants to merge 27 commits into
Draft
Conversation
Zacgoose
force-pushed
the
preview/bec-case-workflow
branch
7 times, most recently
from
September 6, 2026 10:32
665a892 to
0539fb0
Compare
Zacgoose
marked this pull request as ready for review
September 7, 2026 06:29
Zacgoose
marked this pull request as draft
September 7, 2026 11:10
Zacgoose
force-pushed
the
preview/bec-case-workflow
branch
2 times, most recently
from
September 8, 2026 10:30
e51f68c to
50d15f7
Compare
…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
force-pushed
the
preview/bec-case-workflow
branch
from
September 9, 2026 09:12
25a457f to
ba5dd13
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Get-CIPPBecContainmentActionscmdlet, 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.Disable-CIPPInboxRulescmdlet 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.Get-CIPPAlertNewRiskyUsers, running the default containment steps and logging the results directly in the alert payload. [1] [2] [3]Configuration and Heuristics
BecHeuristics.jsonconfiguration 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
ConvertTo-CIPPBecHostAddressutility function to normalize audit-log addresses by stripping ports and IPv6 brackets, enabling consistent host correlation across the platform.Get-CIPPAsyncDeploymentto include aLastUpdatefield, 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.